ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Cocos Creator TiledMap 进阶实战:gid偏移、坐标转换与性能优化

2026/10/8 14:53:08 拓冰建站 浏览量
Cocos Creator TiledMap 进阶实战:gid偏移、坐标转换与性能优化 没错这就是我为什么一直说TiledMap 这种编辑器配套型功能不能只停留在把 tmx 拖进场景能显示的程度。能显示只是开头真正折磨人的是运行时对象层解析、动态改图、大世界性能还有最后构建 APK 时莫名其妙丢资源的问题。上一篇我把资源导入和基础渲染配置讲完了这一篇直接补剩下的硬知识按我实际项目里踩坑的顺序来该给的代码给代码该说的版本坑说版本坑。这篇内容适合手里已经跑通一张地图、但想做交互功能的 Cocos Creator 开发者。无论你是 2.x 老项目迁移还是 3.x 新项目思路基本一致个别 API 名字有出入的地方我会专门标出来。1. tmx 资产进入 Cocos 之后的解析链路与 gid 编号很多人以为把 tmx 文件拖进 scenes 里就完事了其实 Cocos 在背后做的事情不少它会把 tmx 解析成一份 TiledMapAsset里面有完整的地图头信息、tileset 列表、所有图层数据、对象组数据。一旦地图显示不对你就得回头去看 tmx 源文件而不是盯着编辑器猜。1.1 tmx 里真正值得盯的三块数据一张标准 tmx 本质是 XML哪怕 Cocos 已经帮你解析你也得能看懂它。打开地图文件核心就三段map version1.4 tiledversion1.4.3 orientationorthogonal renderorderright-down width32 height32 tilewidth32 tileheight32 tileset firstgid1 nameterrain tilewidth32 tileheight32 tilecount256 columns16 image sourceterrain.png width512 height512/ /tileset layer id1 nameground width32 height32 data encodingcsv 1,1,1,1,1, ... /data /layer objectgroup id2 nameCollision object id1 namewall typeblock x128 y256 width64 height64/ /objectgroup /map第一块是map头地图宽高、tile 宽高、方向类型。第二块是tileset重点关注firstgid和image source。第三块是 layer data常见编码有 CSV 和 Base64Cocos 两种都能读但你在编辑器里看 tmx 的 data 时Base64 压缩成一长串CSV 相对直观。常见的报错场景是地图导入后一片空白这时候先别怀疑 Cocos回去看 tmx 里 image source 指向的图片是 jpg 还是 png路径是否存在。我记得有一次同事把地图图片改成中文名Cocos 编辑器死活不刷新最后把文件名改回英文就恢复了。1.2 firstgid 与 gid 偏移动态改图必踩的雷gid 是 Tiled 里的全局图块编号不是你在图集里看到的局部序号。这个区分决定了动态改图会不会写成看起来对了运行起来错位。Cocos 里每个 tileset 都有firstgid第一张图集从 1 开始第二张图集的第一块砖编号可能就是 65、129取决于前一张图集的 tile 总数。你在编辑器里看到某个格子是第 3 个图块但在代码里 setTileGIDAt 填的 gid 必须加上 firstgid 偏移否则填的是另一张图集或者越界。我踩过的具体坑项目里用了两张 tileset一张是地形 64 格一张是装饰物 32 格。我写了个逻辑点击格子替换成装饰物第 5 个图块直接填了 5结果地形全部变成空白或错乱。正确做法是你拿装饰物层的第一个 gid 加上局部序号 5。结论是不要手算 gid宁可运行时遍历 tileset 数据算出来也别在配置表里写死。// 尽量通过接口拿第一个gid而不是硬编码 const layer tiledMap.getLayer(decoration); const firstGidForDecoration 64; // 实际应由工具从 tmx 解析 layer.setTileGIDAt(firstGidForDecoration 4, col, row);如果你在地图编辑阶段频繁增删图集gid 会整体漂移所以更稳妥的做法是动态改图只用来做临时状态挖坑、填洞、开门需要做存档的场景存档里存层名格子坐标图块局部索引读档时再重新换算全局 gid。1.3 图片路径与文本编码的工程规范tmx 里的图片路径是相对路径一般图片放在和 tmx 同级的 tileset 目录下就行。但构建 APK 后资源路径会变如果你在代码里用resources.load动态加载 tmx请务必保证 tmx 和它引用的图片都被放在 resources 目录内且保持相对位置不变。另外Tiled 里的图层名、对象层名、自定义属性名我强烈建议全部用英文字母下划线。中文不是绝对不行但 Cocos 的 tmx 解析对中文编码在不同平台表现不一致Windows 上正常、Android 上取不到对象名的情况我遇到过两次。这种问题非常难排查与其事后写兼容层不如一开始就定规矩凡是输入到编辑器里的字符串一律英文。2. 运行时图层实战图块层、对象层、图像层分别怎么玩Tiled 有几种图层类型图块层Tile Layer、对象层Object Group、图像层Image Layer。还有自动图块、动画图块这些进阶货。运行时操作它们的 API 逻辑完全不同先分清边界再动手。2.1 图块层不是一堆 Sprite而是一张网格图块层渲染出来是一整张由图块拼接的网格Cocos 底层并不是给每个 tile 创建一个 Sprite 节点而是收集顶点数据统一渲染。这就是为什么 100 x 100 的地图也没把 draw call 打爆。运行时读写图块核心就是四个 APIgetLayer、getTileGIDAt、setTileGIDAt、markForUpdateRenderData。下面是我在实际游戏里写过的挖洞逻辑const layer tiledMap.getLayer(ground); if (!layer) return; // 读取某个格子的gid0表示空格 const oldGid layer.getTileGIDAt(col, row); if (oldGid 0) return; // 挖掉把gid置0 layer.setTileGIDAt(0, col, row); // 如果地图有多个层同步显示需要统一标记刷新 layer.node.emit(tile-changed, { col, row });这里有两个细节。第一个setTileGIDAt的 col 和 row 是 Tiled 坐标系下的行列号不是像素坐标0 行是地图最上面一行。第二个连续大量修改后界面可能出现改了但没刷新的假象这不是 bug是渲染数据还挂着旧缓存。我的经验是在批次修改完成后手动刷新一次渲染数据。3.x 里可以调用layer.markForUpdateRenderData()如果你用的版本没有这个接口搜一下你版本 TiledLayer 的公共方法找到对应的脏标记入口。填洞类似只是要把 gid 从 0 改回目标值。这里再次提醒 gid 偏移见上一节。2.2 对象层地图里的数据金矿对象层在 Tiled 编辑器里通常用来画碰撞区域、标出生点、放传送门。它不参与瓦片渲染纯粹是数据。Cocos 解析后你会得到一个TiledObjectGroup里面每个对象都有自己的名字、类型、矩形/多边形数据、自定义属性。我建议把对象层当成关卡配置表来用。比如关卡里所有怪物出生点都画成一个点对象每个点挂两个自定义属性monsterType和count。这样策划在地图编辑器里就能调整怪物配置不用改代码。代码侧解析如下const group tiledMap.getObjectGroup(SpawnPoints); if (!group) return; for (const obj of group.getObjects()) { // obj.name / obj.type / obj.x / obj.y / obj.width / obj.height // 自定义属性在 obj.properties 里注意取值类型可能是字符串 const monsterType obj.properties.monsterType || soldier; const count parseInt(obj.properties.count || 1, 10); // 根据类型生成怪物节点位置需要做坐标转换 const worldPos mapNode.convertToWorldSpaceAR(new Vec3(obj.x, obj.y, 0)); spawnMonster(monsterType, count, worldPos); }有个坑Tiled 自定义属性如果是数字型导出到 Cocos 后可能变成字符串必须自己 parseInt/parseFloat别直接拿来当数字用。比如你在 Tiled 里填 count5读取时obj.properties.count可能是5字符串拼字符串时看不出来一参与运算就出错。对象层的坐标原点也在左上角和图块层行列坐标不同。矩形对象x/y是左上角位置width/height是矩形宽高。这个细节下一节专门讲。2.3 图像层与图块动画的边界图像层就是一张完整的大图Tiled 里把它当成一个独立图层Cocos 渲染时也是一张 sprite。我一般把远景背景、草地铺底这类不需要交互的东西放图像层。它不能动态改图也没有 gid 概念定位就是一张会跟着地图节点一起移动的大图。图块动画这一块Cocos 对 Tiled 的帧动画 tile 支持是有限的。Tiled 1.x 里可以给某个 tile 定义多帧动画早期版本的 Cocos Creator 读不了或者读出来只有第一帧。如果项目比较新、用的 3.8可以测试一下你那个版本的 Tiled 图块动画是否生效。我的经验是关键动画火焰、水波不要依赖 Tiled 的动画 tile自己在引擎层用 SpriteFrame 序列帧做确保跨版本不炸。3. 坐标换算是所有新人绕不过去的坎Tiled 用的坐标系统和 Cocos 节点坐标系统不一样凡是做点击地块角色踩到哪格从屏幕位置反推格子的需求都会被这个坎绊一次。提前把转换逻辑写成工具函数后面会省很多事。3.1 三套坐标行列、地图像素、节点世界坐标你得分清三种坐标坐标类型原点位置常见用途Tiled 行列坐标 (col, row)地图左上角row 向下递增读写 tile、存档格子地图像素坐标 (px, py)地图左上角px 向右py 向下Tiled 对象层、矩形对象Cocos 节点坐标/世界坐标地图节点锚点y 向上放置角色、碰撞体、UI 换算Tiled 的 (0,0) 是地图左上角向下是 y 正方向Cocos 2D 常规坐标是左下角为原点y 向上。这个y 翻转就是麻烦的源头。假设地图节点放在 Canvas 下、锚点在中心地图总像素高mapHeightPx mapHeight * tileHeight把 Tiled 地图像素(px, py)转成 Cocos 地图节点本地坐标(nx, ny)一个稳妥的公式是const nx px - mapNode.width * mapNode.anchorX; const ny (mapHeightPx - py) - mapNode.height * mapNode.anchorY;注意如果地图节点加了 scale 或旋转先除回缩放再继续算。3.2 屏幕点击反推瓦片格子的完整流程这个需求太常见了玩家点击屏幕你要知道点中了地图哪一格。完整链路是屏幕坐标 - 相机转换出世界坐标 - 转换到地图节点本地坐标 - 换算成 Tiled 行列。// 假设 event 来自 UITransform 的触摸事件或者鼠标事件 const worldPos event.ui.getUILocation(); // 屏幕像素坐标 const localPos mapNode.getComponent(UITransform).convertToNodeSpaceAR(new Vec3(worldPos.x, worldPos.y, 0)); // 地图尺寸格子数 const mapW tiledMap.getMapSize().width; const mapH tiledMap.getMapSize().height; // 格子像素 const tileW tiledMap.getTileSize().width; const tileH tiledMap.getTileSize().height; let col Math.floor(localPos.x / tileW); let row Math.floor((mapH * tileH - localPos.y) / tileH); // 越界判断 if (col 0 || col mapW || row 0 || row mapH) return null;先转换到地图节点坐标再统一除 tile 宽高不要拿世界坐标直接除否则地图节点一移动就全错。convertToNodeSpaceAR这个接口是带锚点语义的如果地图节点没有位移用普通 convertToNodeSpace 也行但写工具函数时建议固定用 AR 版本避免锚点不一样导致对不齐。3.3 等距地图的坐标坑如果地图方向是 45 度等距isometric上面那套公式不能直接用。行列转像素坐标的公式是const nx (col - row) * tileW / 2; const ny (col row) * tileH / 2;这个公式的语义是在菱形地图里列号增大往右下走行号增大往左下走。反向由像素反推行列时需要解一个二元一次方程。Cocos 对等距地图的支持一直比较弱我在实际项目里只用正交地图做主玩法等距地图最多做个大地图背景因为等距地图的角色遮挡排序、物体高度、碰撞体积每一样都是额外的工程时间不是简单 API 能解决的。4. 从 Tiled 对象层到碰撞体两种落地方案地图碰撞是个经典问题。最常见做法是在 Tiled 里用对象层画好固体区域然后在 Cocos 运行时根据这些矩形/多边形生成碰撞体。落地时有两个方案按项目规模取舍。4.1 方案一物理引擎 程序化生成 Collider项目里如果有完整的物理需求子弹、敌人、角色受击直接用引擎自带的物理系统遍历对象层动态创建碰撞体是最省事的。import { _decorator, Component, TiledMap, Node, BoxCollider2D, PolygonCollider2D } from cc; const group tiledMap.getObjectGroup(Collision); if (!group) return; for (const obj of group.getObjects()) { const collisionNode new Node(obj.name || collider); collisionNode.parent this.node; // 对象层的 x/y 是左上角物理组件默认锚点在中心需要偏移 const centerX obj.x obj.width / 2; const centerY obj.y obj.height / 2; collisionNode.setPosition(centerX, centerY); if (obj.polygonPoints obj.polygonPoints.length 2) { const poly collisionNode.addComponent(PolygonCollider2D); poly.points obj.polygonPoints; // 注意这里应当使用相对节点原点的点集合 } else { const box collisionNode.addComponent(BoxCollider2D); box.size.width obj.width; box.size.height obj.height; } }坐标这里有个细节Tiled 对象层的 y 值是从上往下数的直接给 Cocos 节点用会上下颠倒。处理办法和上一节坐标换算一样先做 y 翻转。矩形对象的x/y是左上角所以生成碰撞体时要把中心点移动到(x width/2, 翻转后的y - height/2)否则撞空气。物理方案的优点是成熟稳定但代价是静态碰撞体每个都要生成节点和组件地图块数多的时候场景节点会变重。几百个静态碰撞体没问题上万个就得想别的办法。4.2 方案二轻量级 AABB 自检如果玩法简单比如只有角色移动和几个阻挡墙不想引入物理引擎可以直接把碰撞区域收集成普通数组每帧做 AABB 检测。这样连 Collider 组件都不用生成。interface AABB { x: number; y: number; w: number; h: number; } // 收集阶段一次性把对象层数据转成 AABB 数组 function collectAABBs(group: TiledObjectGroup): AABB[] { const boxes: AABB[] []; for (const obj of group.getObjects()) { boxes.push({ x: obj.x, y: flipY(obj.y obj.height), // 转成左下角坐标系 w: obj.width, h: obj.height, }); } return boxes; } // 判断角色下一个位置是否会撞上任何盒子 function isBlocked(boxes: AABB[], nextPos: Vec2, size: { w: number; h: number }): boolean { for (const b of boxes) { if (nextPos.x b.x b.w nextPos.x size.w b.x nextPos.y b.y b.h nextPos.y size.h b.y) { return true; } } return false; }注意我把 y 翻转放在收集阶段这样后面做角色检测时就不需要每次都管 Tiled 坐标系逻辑更干净。角色碰撞体建议比实际格子小 2-4 像素这是老经验了不然经过墙边时视觉上已经没碰到、逻辑上却卡住了玩家会觉得手感非常黏。4.3 对象层命名与属性约定的工程化经验对象层最终要被人和代码共同理解命名规范直接影响维护成本。我用的约定是这样的层名用途生成物Collision静态阻挡碰撞体Trigger触发器事件节点SpawnPoints出生点怪物/玩家Teleport传送点传送脚本每个对象用type字段区分行为比如typedoor、typespike用自定义属性存参数比如targetMap、damage。脚本里只做一层映射表根据 type 分发到不同的处理函数而不是在代码里到处写死层名。有一次我把传送门数据直接写在 Tiled 对象名里比如 door_to_2_3_5结果策划改需求后所有名字都变得不可读最后不得已重新导出一版。后来学乖了对象名只做标识所有数据都放 custom properties。这是我能给的最实在的工程建议。5. 动态改图、性能与大世界的取舍地图跑起来只是第一步。真正上强度是在你开始做挖坑、破坏地形、动态生成内容的时候。这一节专门讲运行时动态修改和性能之间的平衡。5.1 setTileGIDAt 卡顿的真因与批量更新新手写挖格子时最常犯的错是在触摸回调里每帧多次调用 setTileGIDAt或者一次循环改几千个格子。你看到的现象是画面掉帧或者改了没反应。原因不是 setTileGIDAt 本身慢而是每次调用都可能触发图块层渲染数据的局部重建。如果是几千次连续调用等于让渲染系统反复标记脏数据。正确做法是攒成一批在合适的时机统一提交。const pendingChanges: Array{ layer: string; col: number; row: number; gid: number } []; function planTileChange(layerName: string, col: number, row: number, gid: number) { pendingChanges.push({ layer: layerName, col, row, gid }); } function flushTileChanges() { for (const change of pendingChanges) { const layer tiledMap.getLayer(change.layer); if (layer) { layer.setTileGIDAt(change.gid, change.col, change.row); } } pendingChanges.length 0; // 不同版本刷新入口不一样挑一个你们版本可用的 // tiledMap.getLayer(ground)?.markForUpdateRenderData(); }另一个隐蔽问题如果你在遍历某个层做逻辑判断时又同时往里面 setTileGIDAt会导致迭代数据不一致。我的做法是永远分两步先收集所有格子坐标和旧 gid逻辑判断结束后再统一 flush 修改。这个习惯帮我避免了大量诡异 bug。5.2 TiledMap 渲染裁剪机制解读TiledMap 能承载大地图核心原因是它不是每个瓦片一个 Sprite而是整层网格数据引擎在渲染时只提交相机可见范围内的瓦片。所以你把地图画得很大只要视野小渲染压力并不会线性爆炸。但有几件事会打破这个优势图层拆得太碎比如一层的功能拆成 20 个图层每个层都要单独做剔除和提交draw call 随之上涨。图块大小不统一地图里混用 16px、32px、64px 图块可能影响合批。超大 tileset 动辄 2048x2048图片尽量压缩tileset 图片尺寸保持 2 的幂避免无谓的显存开销。我体感上的安全线单张地图总格子数在 3 万以内、图层数不超过 8 个、图块图片单张不超过 1024x10242D 项目跑起来很稳。超过这个量级就该考虑分块加载。5.3 大世界分块加载与 ySort 遮挡真正的大世界不要做成一张超大地图而是切成多个小地图节点根据相机位置动态加载和回收。比如每个区块是 32x32 格子一张大世界横向 10 个区块就用 10 个 TiledMap 节点管理。加载时只实例化相机周围的 3x3 或 5x5 区块离开视野的节点直接回收。function updateChunksByCamera(cameraPos: Vec2) { const chunkSize 32 * tilePixel; const cx Math.floor(cameraPos.x / chunkSize); const cy Math.floor(cameraPos.y / chunkSize); for (let dx -1; dx 1; dx) { for (let dy -1; dy 1; dy) { loadChunk(cx dx, cy dy); } } unloadFarChunks(cx, cy, 3); }分块加载的代价是区块边缘要处理拼接比如河流、道路的衔接。如果游戏不是开放世界只是关卡制老老实实做独立小地图是性价比最高的。地图里的角色遮挡排序经典做法是 ySort同一水平线上越是 y 值小的角色画在越下面先渲染y 值大的角色覆盖在上面。Cocos 里可以给角色节点设置siblingIndex来调整渲染顺序或手动管理容器内子节点的排序。const sortables [playerNode, treeNode, npcNode]; sortables.sort((a, b) a.position.y - b.position.y); for (let i 0; i sortables.length; i) { sortables[i].setSiblingIndex(i); }这个排序一般每帧做一次对象数量不大时性能没问题。如果地图里树很多、角色很多就要改成局部排序或者用分层 AOI 方案但常规项目不必过度设计。6. 系列二必须补上的工程坑兼容性、版本、打包最后这部分是我最想说的很多问题不是因为你不懂 TiledMap 的 API而是因为编辑器版本、构建管线、资源组织方式这些工程化细节在背后给你捣乱。6.1 Tiled 编辑器版本与 Cocos 兼容矩阵Tiled 本身更新很快但 Cocos 对 Tiled 新特性的支持永远有滞后。我的建议是用 Tiled 1.4.x 编辑地图保存时选择支持的格式。如果你装了新版 Tiled另存为的时候看下 tmx 版本号是不是变成了 1.6/1.8一旦用了新版格式拉回 Cocos 可能直接打不开。特性Cocos 官方支持态度我的建议正交地图支持主力玩法全用正交45度等距可用但要手动处理遮挡小范围使用六边形地图支持程度弱直接放弃Infinite 无限地图部分版本不支持编辑时不要勾选图块动画 tile版本差异大不用引擎自己做自动图块 Terrain读取不稳定不用手动铺设其中 infinite 是最容易踩的雷。Tiled 里新建地图默认不勾选 Infinite但如果勾了导出 tmx 内部用 chunk 方式存储瓦片数据Cocos 旧版解析不出来。我发现这个问题时已经画了 60% 的地图最后只能导出成非 infinite 重来。所以一开始就要检查地图属性里的 Infinite 选项。6.2 打包 APK 后地图资源丢失的排查链这个坑我替不少朋友排查过症状都很一致编辑器里运行地图一切正常构建 APK 装到手机上地图区域空白或者直接报错找不到资源。排查顺序按下面来命中率很高确认 tmx 是不是放在了 resources 目录下。不在 resources 里的资源编辑器能拖进场景但运行时动态加载会找不到。确认 tmx 引用的图片也放在 resources 目录内并且路径保持相对关系。图片在 resources 外、tmx 在 resources 内这种组合构建时图片不会被打进去。检查文件名大小写。Android 资源系统对路径大小写敏感Windows 不上心一上 Android 就现形。文件名统一小写是成本最低的预防手段。看构建日志里有没有资源打包失败。Cocos 构建面板的日志会把每个资源复制是否成功打出来一般地图图片加载失败会留下蛛丝马迹。如果用了 Asset Bundle确认加载 Bundle 的时机和 Bundle 名字是对的。地图资源被拆到另一个 Bundle 里主包代码没加载 Bundle 就 load也会表现为地图消失。有一次我排查了三小时最后发现是地图图片文件名里带了一个空格Windows 正常Android 上资源路径被截断。从那以后凡是进项目的地图资源我都强制走一遍命名检查脚本。6.3 还有几个容易被忽视的细节Tiled 对象层里画矩形时如果对象宽度/高度为 0Cocos 可能不生成有效碰撞体。放置点对象时确认一下 width/height 属性。tmx 引用的图片不要用 Tiled 内置的嵌入式图集保存把图片嵌进 tmx 文件这会大幅增加解析压力而且在构建时容易出问题。图片单独导出tmx 引用外部图片路径。修改地图后 Cocos 编辑器不刷新八成是资源数据库缓存问题。手动在资源管理器里右键刷新或者重启编辑器别反复拖拽。地图节点不要塞到带缩放动画的父节点里做连续缩放TiledMap 的渲染数据在非整数缩放下可能产生边缘黑线。我一般用外面包一层空节点做整体位移地图节点本身保持整数缩放。最后再分享一个我自己的习惯地图编辑器里我只画几何和对象所有玩法参数全部走自定义属性代码里只维护一张 type 到处理器的映射表。前期多花一小时定规范后期能省掉好几天的联调时间。这一篇讲的运行时知识点都是这套规范落地时真正会遇到的细节希望你能少踩一半我踩过的坑。