Phaser Tilemap 与 Tiled 关卡流水线:地图不是一张背景图

从 Tiled 地图制作、Tilemap 图层、碰撞层、对象层和关卡配置出发,讨论 Phaser 项目如何建立可维护的 2D 关卡生产流水线。

地图问题通常不是美术问题

很多 Phaser 项目第一次做关卡时,会把地图当成一张大背景图。美术给一张完整 PNG,工程把它放进场景,再手写几个碰撞矩形和出生点。第一关这样做很快,第三关还能忍,第十关开始就会出问题:玩家能穿过某些墙,怪物刷在障碍物里,隐藏宝箱位置和视觉对不上,策划想改一个平台高度却要重新导整张图。

我遇到过一个横版跳跃 H5 项目,早期为了赶活动,所有关卡都用大图加手写碰撞。上线前一天,策划把第二关右侧平台上移了 16 像素,结果碰撞层没跟着改,玩家看起来已经踩到平台,实际却掉了下去。这个问题不是某个人粗心,而是地图生产方式没有把视觉、碰撞、对象和配置放在同一条流水线里。

Phaser 的 Tilemap 能把地图拆成更可维护的结构。Tiled 负责编辑瓦片、图层、对象点和自定义属性,Phaser 负责加载 JSON、创建图层、设置碰撞和读取对象。真正的关键不是会不会调用 make.tilemap,而是团队是否建立了一套关卡数据从制作到运行的约定。

flowchart TD
    A[Tiled 制作地图] --> B[导出 JSON 和 tileset]
    B --> C[资源清单登记]
    C --> D[Phaser Preload 加载]
    D --> E[创建 Tilemap 图层]
    E --> F[设置碰撞层]
    E --> G[读取对象层]
    G --> H[生成玩家/敌人/道具/触发器]
    F --> I[运行期调试和校验]
    H --> I

先把图层职责说清楚

Tiled 里的图层不要只按视觉顺序命名。一个成熟关卡至少应该区分背景层、装饰层、地面层、碰撞层、前景遮挡层、对象层和触发层。背景层和装饰层影响视觉,不应该参与碰撞;地面层可以可见且参与碰撞;碰撞层可以不可见,只表达规则;对象层放出生点、怪物点、宝箱、门、相机区域和剧情触发器。

如果图层职责混在一起,后期就会很痛。比如把装饰草丛也设置成碰撞,玩家会莫名被挡住;把碰撞直接依赖可见瓦片,后面美术换瓦片时规则也跟着变;把怪物出生点写在代码里,策划就无法在地图编辑器里调整节奏。好的图层命名会让地图文件本身变成关卡文档。

建议团队约定一组固定图层名,例如 bg_farbg_neargroundcollisionforegroundobjectstriggers。Phaser 代码只识别这些约定名。关卡编辑器里新增临时层可以存在,但不会进入运行逻辑。这样美术和策划可以自由组织视觉层,工程也能保证运行时只读取可信层。

碰撞层要服务手感

很多人以为碰撞层越贴合画面越好。实际平台游戏里,碰撞层常常要比视觉更“圆滑”。视觉上的碎石边缘、草叶、斜角和小凸起,如果全部进入碰撞,会让玩家走路卡顿、跳跃挂边、子弹莫名反弹。碰撞层的目标不是复刻图片,而是让玩家的移动结果符合预期。

Phaser Tilemap 支持按 tile 设置碰撞,也可以基于 Tiled 属性设置不同类型。简单项目可以把 collision 图层里的非空 tile 全部设为碰撞。更复杂的项目可以给 tile 写属性:solidoneWayslopedamagewater。Phaser 读取这些属性后,按规则创建碰撞或触发区。

单向平台尤其需要谨慎。它看起来只是从下往上能穿过,从上往下能站住,但实际要处理角色上升、下降、跳跃取消、下蹲穿越、平台移动和相机抖动。不要把单向平台完全交给默认碰撞。最好把它作为一种语义层读取,再由角色控制器判断什么时候启用碰撞。

对象层是关卡逻辑入口

Tiled 对象层很适合表达关卡里的语义点。玩家出生点、敌人巡逻点、宝箱、传送门、剧情触发器、相机锁定区域,都可以作为对象保存。对象可以有坐标、尺寸、类型和自定义属性,Phaser 读取后生成对应游戏对象。

不要把对象层当成随手放点的工具。它需要命名规范和属性规范。比如敌人对象可以要求 type=enemyenemyId=slime_smallpatrolGroup=a1;宝箱对象可以要求 type=chestlootTable=stage_02_common;触发器可以要求 type=triggereventId=boss_introonce=true。没有这些规范,地图很快会变成一堆只有作者能看懂的点。

工程侧应该对对象层做校验。缺少 enemyId 的敌人点不应该静默忽略,出生点重复也应该报错。最糟糕的是地图导出成功,游戏运行时某个对象没生成,测试只能靠肉眼发现。关卡数据越早失败,修复成本越低。

资源 key 要稳定

Tilemap 的资源引用经常出问题。Tiled 里引用的 tileset 图片路径、Phaser 加载时使用的 key、发布到 CDN 后的实际路径,三者只要有一个不一致,就可能黑屏或白块。小项目靠手工能撑一阵,大量关卡时必须建立资源清单。

比较稳的做法是每张地图有一个 manifest,列出 map JSON、tileset 图片、关联音频、背景图和预加载对象。PreloadScene 根据 manifest 加载资源,GameScene 根据同一个 manifest 创建地图。不要让关卡文件自己偷偷依赖一个没有登记的资源。

如果多个关卡共用 tileset,key 命名要避免冲突。比如 tileset_common_foresttileset_dungeon_01。不要直接用 tilesmap 这类宽泛 key。Phaser cache 是运行期共享的,key 乱了以后,第二张地图可能拿到第一张地图的资源。

关卡校验要自动化

关卡越多,越不能依赖人工点测。可以写一个轻量校验脚本,读取 Tiled JSON,检查固定图层是否存在、对象属性是否完整、出生点是否唯一、触发器是否有 eventId、tile gid 是否引用有效 tileset、碰撞层是否覆盖关键区域。这个脚本不需要理解所有玩法,只要能抓住低级错误,就已经很有价值。

Phaser 运行时也可以做开发版校验。加载地图后,在 debug 面板显示图层列表、碰撞 tile 数量、对象数量、缺失资源和触发器区域。QA 截图给工程时,工程能马上知道地图结构是否正确,而不是先怀疑输入、物理或浏览器。

校验还可以服务内容协作。策划提交地图文件后,CI 或本地脚本直接告诉他第 3 个宝箱缺少 lootTable,第 2 个门没有 targetMap。错误反馈越靠近制作工具,返工越少。

相机边界也属于地图数据

很多关卡会把相机边界写在代码里,例如 camera.setBounds(0, 0, 3200, 720)。这在固定地图里没问题,但关卡多了以后,代码就要不断跟着地图尺寸变化。更好的做法是从 Tilemap 尺寸或对象层读取相机边界。

Boss 房、剧情区域和锁屏战斗也可以用对象层表达。比如在 Tiled 里放一个 camera_zone 矩形,属性写 mode=locktarget=boss_room_01。玩家进入触发器后,Phaser 读取区域并限制 camera bounds。这样关卡设计师可以在地图里调整镜头,而不必找工程改代码。

但相机边界必须和玩法边界分开。相机不让玩家看到某处,不代表物理世界没有那里。玩家能不能走过去,仍然由碰撞和关卡规则决定。镜头只是观看方式,不应该偷偷承担地图阻挡职责。

热更新和旧地图兼容

H5 游戏经常热更新地图。地图 JSON、tileset 图片、关卡配置和代码版本必须匹配。如果只更新地图,不更新对应资源,玩家可能进入半新半旧的关卡。尤其是在 CDN 缓存下,旧 JSON 引用新图片或新 JSON 引用旧图片都可能发生。

建议给地图资源加版本号或 hash。manifest 中明确写出地图版本、tileset 版本和配置版本。加载前先确认 manifest 完整,再加载具体资源。地图热更新失败时,宁可停留在旧关卡,也不要让玩家进入缺资源的地图。

旧地图兼容也要考虑。比如早期对象层字段叫 monsterId,后面改成 enemyId。不要直接删除旧字段读取逻辑,至少要有迁移或兼容层。否则历史关卡重新开放时,很容易出现怪物消失。

可操作的落地步骤

第一步,不要马上改所有关卡。先选一张中等复杂度地图,按固定图层拆分,建立 Tiled 命名规范和对象属性规范。第二步,在 Phaser 里写一个 MapLoader,只负责加载 manifest、创建 tilemap、创建图层和导出对象数据。第三步,把玩家出生、敌人生成、宝箱生成和触发器注册从 GameScene 里移到关卡生成服务。

第四步,增加开发版 debug overlay,显示碰撞层、对象点和相机区域。第五步,写最小校验脚本,先检查图层和必填字段。第六步,和策划、美术一起走一次真实改图流程:移动平台、加怪、加宝箱、改触发器,看是否需要工程介入。如果每个小改动都要改代码,说明流水线还没有建立起来。

function createLevel(scene: Phaser.Scene, key: string) {
  const map = scene.make.tilemap({ key });
  const tileset = map.addTilesetImage('forest_tiles', 'tileset_common_forest');
  const ground = map.createLayer('ground', tileset);
  const collision = map.createLayer('collision', tileset);
  collision?.setCollisionByProperty({ solid: true });

  const objects = map.getObjectLayer('objects')?.objects ?? [];
  for (const obj of objects) {
    spawnFromTiledObject(scene, obj);
  }
  return { map, ground, collision };
}

这段代码故意很短,因为真正重要的是约定:图层名稳定,tileset key 稳定,对象层由统一函数解释。GameScene 不应该知道每一种 Tiled 对象怎么生成,它只需要请求“创建第几关”。

上线前检查清单

上线前至少检查这些问题:地图是否有固定图层,碰撞层是否可视化调试,玩家出生点是否唯一,所有对象是否有必填属性,tileset key 是否和 preload 一致,旧地图字段是否兼容,相机边界是否来自地图数据,热更新 manifest 是否完整,低端设备加载大地图是否会卡顿。

还要实际跑一次“改图回归”。让策划移动一个敌人点、增加一个触发器、替换一块 tile,确认工程不需要改代码。地图流水线的价值就在这里:内容生产能稳定迭代,工程只维护规则解释器,而不是每张地图写一次特殊逻辑。

如果团队已经有旧关卡,不建议一次性重做全部地图。可以先挑一张问题最多的关卡做样板,把图层、对象、资源和校验都打通,再把规范写到项目文档里。后续迁移时,每次只迁一组关卡,并保留旧加载器一段时间。这样即使新流水线有遗漏,也不会影响所有已发布内容。

结语

Phaser Tilemap 和 Tiled 的组合很适合 2D H5 游戏,但它不是把地图导进来就结束。地图是视觉、碰撞、对象、镜头和触发器的综合数据。把这些数据放进一条清楚的生产流水线,关卡才会越做越快,而不是越做越怕。

地图不是一张背景图。它是玩家体验空间,也是团队协作接口。只要图层职责、对象属性、资源 key、校验脚本和运行期调试都建立起来,Phaser 项目就能承受更多关卡、更频繁的活动和更复杂的玩法变化。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页