为什么这个系统值得单独设计
一款矿洞探索游戏里,玩家用炸药打开隐藏通道。爆炸后,岩层被挖出一个不规则缺口,小石块飞散,敌人路径改变,光线透进洞穴。画面很爽,但如果地形只是一张背景图,角色碰撞、寻路、存档和撤销都会马上失效。
可破坏地形的核心不是爆炸特效,而是地形数据的局部变更。Phaser 的 Tilemap 可以承载规则,但不能每次爆炸都全图重建。系统需要知道哪些格子被破坏、哪些碰撞体要刷新、哪些视觉块要重画、哪些变化要写入存档。 本文按实际项目会遇到的问题来拆,不停留在“能跑”的 Demo 层。重点会放在数据边界、状态流、玩家反馈、调试方式和后续维护成本上。Phaser 很适合快速做出手感,但越是能快速表现,越需要把规则层写清楚。
核心架构
flowchart TD
N1["ExplosionRequest"] --> N2["DamageMask"]
N2["DamageMask"] --> N3["TerrainGrid"]
N3["TerrainGrid"] --> N4["DirtyChunks"]
N4["DirtyChunks"] --> N5["ColliderRefresh"]
N4["DirtyChunks"] --> N6["TilemapRenderer"]
N2["DamageMask"] --> N7["DebrisSpawner"]
N3["TerrainGrid"] --> N8["SavePatch"]
这套结构的原则是单向流动:输入或场景事件进入 ExplosionRequest,核心模型完成计算,再由 Phaser 表现层消费结果。DamageMask、TerrainGrid、DirtyChunks、ColliderRefresh、DebrisSpawner、SavePatch 都应尽量保持可序列化、可测试、可回放。不要让某个 Tween 完成回调、某个 Sprite 是否可见、某个按钮是否高亮成为玩法事实。
爆炸先生成掩码
不要让粒子范围直接决定地形破坏。爆炸请求包含中心、半径、强度、形状和来源,然后生成 DamageMask。掩码可以是圆形、椭圆、扇形或噪声边缘,它输出受影响格子和伤害值。这样同一种爆炸既能破坏地形,也能给敌人、道具和声音系统提供一致输入。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
地形格要保存耐久
不是所有地形都应该一炸就空。每个格子可以有 material、hp、solid、dropTable、lightBlock 等字段。泥土 hp 低,岩石 hp 高,金属墙不可破坏。爆炸只修改模型,Tilemap 只是模型的表现。后续要做酸液腐蚀、钻头或技能穿透,也能复用同一套耐久规则。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
局部 chunk 比全图刷新可靠
Tilemap 大时,全图 setCollisionByProperty 和重建碰撞体会卡顿。把地图切成 chunk,爆炸后标记脏 chunk,只刷新附近碰撞和显示。刷新时加一圈邻居边界,因为破坏一个格子可能改变相邻格子的自动连接贴图。这个边界处理如果省略,洞口边缘很容易出现错贴。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
碰撞刷新要延迟到安全点
如果角色正站在被破坏格子上,立刻刷新碰撞可能把角色弹飞或卡进墙里。可以在物理步结束后统一处理 DirtyChunks,并在刷新后执行一次角色位置校正。对被清空的地形,允许角色自然下落;对新增地形,必须先检测是否会压住角色。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
碎屑是结果不是数据
爆炸飞出的石块很有表现力,但它们不应该影响地形真相。DebrisSpawner 根据 DamageMask 生成短生命周期的 Sprite 或粒子,数量按性能档限制。掉落物则由地形材料和概率表决定,是可拾取实体,不能和装饰碎屑混在一起。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
寻路和视线要订阅变化
破坏地形会改变敌人路线和光线遮挡。不要让寻路系统每帧扫描地图。TerrainGrid 发出 terrainChanged(chunks) 事件,寻路缓存、光照遮挡和小地图只更新受影响区域。这样爆炸频繁时,系统仍然可控。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
存档保存差异而不是整图
关卡原始地形来自资源文件,存档只保存破坏差异:格子坐标、剩余 hp、材料变化或清空状态。读档时先加载原图,再应用 patch。这样修关卡资源时,旧存档还能尽量兼容,也避免把整张 Tilemap 塞进本地存储。
落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。
TypeScript 实现骨架
interface TerrainCell { material: string; hp: number; solid: boolean; drop?: string }
interface DamageCell { x: number; y: number; amount: number }
function circularMask(cx: number, cy: number, radius: number, damage: number): DamageCell[] {
const cells: DamageCell[] = [];
for (let y = Math.floor(cy - radius); y <= Math.ceil(cy + radius); y++) {
for (let x = Math.floor(cx - radius); x <= Math.ceil(cx + radius); x++) {
const d = Phaser.Math.Distance.Between(cx, cy, x + 0.5, y + 0.5);
if (d <= radius) cells.push({ x, y, amount: damage * (1 - d / radius) });
}
}
return cells;
}
function applyDamage(grid: Map<string, TerrainCell>, mask: DamageCell[], dirty: Set<string>) {
for (const hit of mask) {
const key = `${hit.x},${hit.y}`;
const cell = grid.get(key);
if (!cell || cell.material === "metal") continue;
cell.hp -= hit.amount;
if (cell.hp <= 0) { cell.solid = false; cell.material = "empty"; }
dirty.add(`${Math.floor(hit.x / 16)},${Math.floor(hit.y / 16)}`);
}
}
这段代码不是完整框架,而是把关键边界先立出来。实际项目里应继续补上配置加载、错误码、事件派发、性能统计和单元测试。只要骨架保持清楚,后续接入 Phaser 的 Graphics、Sprite、Matter、Tilemap 或 Sound 都不会污染规则层。
具体落地步骤
- 第一步,把 ExplosionRequest 和 DamageMask 从 Scene 中拆出来,写成可以直接用 TypeScript 调用的模型。这个模型只接收普通对象,不接收 Sprite、Camera 或 Tween。只要这一步做到,后面的测试、调试、存档和工具预览都会简单很多。
- 第二步,在 Phaser Scene 里建立很薄的适配层。输入事件、物理回调、计时器和资源加载都可以在适配层发生,但它们只提交意图,不直接改核心状态。核心系统产出快照后,适配层再更新显示对象、音效、粒子和 HUD。
- 第三步,给每个关键状态准备调试可视化。不要等 QA 报问题才补日志。开发模式下至少能看到当前状态、最近输入、失败原因、候选列表、耗时和重要阈值。对复杂玩法来说,能看见中间状态比多写一层封装更重要。
- 第四步,用三类样例保护系统:正常流程、边界流程、错误配置。正常流程证明体验能跑通,边界流程证明快速输入、暂停、切场景和重复触发不会破坏状态,错误配置证明系统会给出明确报告,而不是静默失败。
项目检查清单
- 确认 ExplosionRequest 的输入输出能被 JSON 记录,便于复现玩家操作。
- 确认 DamageMask 的配置有默认值、版本号和校验错误信息。
- 确认快速点击、暂停、切后台、重开场景和读档不会重复提交关键状态。
- 确认失败反馈比成功反馈更具体,玩家能理解自己为什么没有成功。
- 确认低端机或高负载场景有降级策略,而不是等帧率下降后再猜瓶颈。
- 确认调试面板能在不改代码的情况下打开,并能导出最近关键事件。
常见误区
第一类误区,是把 Phaser 的显示对象当成状态来源。显示对象适合表达结果,却不适合保存规则事实。它可能被对象池回收、被摄像机隐藏、被动画临时修改,也可能因为画质档变化而不存在。核心状态必须独立存在。
第二类误区,是只为当前关卡写逻辑。当前关卡对象少、节奏慢、输入简单,临时判断看起来没有问题。等到内容增加、节奏加快、平台变多,临时逻辑会互相覆盖。每个系统至少要提前考虑配置错误、重复触发和性能上限。
第三类误区,是没有把失败当成流程设计。复杂系统一定会失败:条件不满足、资源缺失、网络超时、玩家中断、配置非法。失败不应该只是 console 里的一行错误,而应该是玩家、QA 和内容团队都能理解的状态。
爆炸后的边界修补
可破坏地形最影响观感的是边缘。玩家不会盯着整块岩层是否少了几个格子,却会立刻注意到洞口边缘是否破碎、碰撞是否顺滑、角色是否被看不见的角卡住。实际项目里,可以给每个材料准备一组边缘 tile,根据相邻八方向自动选图。刷新脏 chunk 时,不只刷新被炸掉的格子,也刷新外扩一圈的邻居格子。视觉边缘和碰撞边缘可以不同步:视觉可以更碎,碰撞可以稍微平滑,避免玩家被尖角阻挡。调试模式下把真实碰撞轮廓画出来,设计师能马上判断某个洞口是看起来能过,还是规则上真的能过。
结语
可破坏地形:Tilemap 重建、碰撞刷新和爆炸边界 的难点不在某个 API,而在边界。把数据、规则、表现和调试分开后,Phaser 的优势会更明显:你可以很快做出反馈,也可以放心迭代规则。反过来,如果所有逻辑都散落在 Scene 的回调里,第一版越快,后续越难维护。
额外实践建议
- 第一版只支持格子级破坏也没问题,关键是让数据变化、碰撞刷新和表现生成走同一条事件链。
- 爆炸半径、伤害衰减和材料耐久都应该放在配置里,便于内容团队调手感。
- 调试面板要能显示脏 chunk、碰撞刷新耗时和破坏 patch 大小,否则后期很难判断卡顿来自哪里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。