Phaser 群体移动与避让:拥堵、队列和低成本 AI

介绍 Phaser 中大量单位的群体移动、局部避让、拥堵检测、队列疏导、性能分层和调试可视化。

为什么这个系统值得单独设计

一款城镇经营游戏里,早晨 8 点工人从宿舍走向矿井,商人推车去市场,巡逻队穿过广场。屏幕上同时有上百个角色,如果每个人只沿最短路径走,他们会在门口挤成一团,像坏掉的传送带。

群体移动要解决的不是单个角色能不能到终点,而是很多角色同时移动时是否自然、是否不堵死、是否性能可控。Phaser 的显示层可以承受不少 Sprite,真正的压力在路径计算、邻居查询和状态更新。 本文按实际项目会遇到的问题来拆,不停留在“能跑”的 Demo 层。重点会放在数据边界、状态流、玩家反馈、调试方式和后续维护成本上。Phaser 很适合快速做出手感,但越是能快速表现,越需要把规则层写清楚。

核心架构

flowchart TD
  N1["目标点"] --> N2["GlobalPath"]
  N2["GlobalPath"] --> N3["LocalSteering"]
  N4["NeighborGrid"] --> N3["LocalSteering"]
  N3["LocalSteering"] --> N5["AgentVelocity"]
  N5["AgentVelocity"] --> N6["CongestionMeter"]
  N6["CongestionMeter"] --> N7["QueueResolver"]
  N5["AgentVelocity"] --> N8["AgentView"]

这套结构的原则是单向流动:输入或场景事件进入 GlobalPath,核心模型完成计算,再由 Phaser 表现层消费结果。LocalSteering、NeighborGrid、CongestionMeter、QueueResolver、AgentView 都应尽量保持可序列化、可测试、可回放。不要让某个 Tween 完成回调、某个 Sprite 是否可见、某个按钮是否高亮成为玩法事实。

全局路径只给方向

大量单位不应该每帧跑完整 A*。可以先按导航网格或路点得到全局路径,再由局部 steering 负责避让。全局路径告诉角色下一段走向哪里,局部避让决定这一秒怎么绕过邻居。二者分开后,门口拥堵不会导致所有角色重新寻路。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

邻居查询必须分桶

局部避让需要知道附近有哪些角色。直接两两比较是 O(n²),一百个单位还行,五百个就会明显卡。把世界划成网格桶,每个角色按位置进入桶,查询时只看周围几个桶。桶大小接近角色避让半径即可,太小查很多桶,太大会候选过多。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

避让要保留目标意图

角色需要同时考虑目标方向、邻居排斥、墙体避让和速度平滑。如果排斥力太强,角色会在终点附近抖动;如果太弱,会互相穿插。建议把 desiredVelocity 和 avoidanceVelocity 合成,再限制最大转向角。这样角色不会瞬间掉头,也能持续向目标推进。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

拥堵检测比硬挤更重要

门口、桥梁和商店柜台会天然拥堵。CongestionMeter 记录某区域内单位数量、平均速度和等待时间。当等待时间过长时,QueueResolver 可以让后排角色进入排队状态、寻找备用入口或临时降低目标优先级。不要让所有人都用同一套避让力硬挤。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

队列是状态不是动画

排队买东西、等电梯、进矿井都需要明确槽位。队列系统给每个角色分配 slot,角色走到 slot 后停止或缓慢前进。槽位变化时只移动受影响角色。这样队伍会稳定,不会出现前后角色互相交换位置的视觉噪声。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

更新频率可以分层

离镜头远的角色不需要每帧精细避让。可以按可见性和重要度分层:屏幕中心角色 60fps 更新,边缘角色 15fps,远处角色只按全局路径低频移动。视图插值让运动仍然顺滑。这个优化对城镇和模拟经营项目很有效。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

调试要画速度和拥堵

开发模式显示角色目标方向、最终速度、邻居数量、所在桶、拥堵区域和队列 slot。群体移动的问题很难靠肉眼判断,看到一堆角色挤在门口时,你需要知道是路径错、桶太大、排斥力太弱还是队列没有接管。

落地时可以先用最朴素的调试图形验证规则,再替换成正式美术。这个顺序很重要:如果规则层还没稳定就开始堆特效,后面每一次调参都会同时牵动动画、音效和 UI,问题会变得难以判断。

TypeScript 实现骨架

interface Agent { id: string; x: number; y: number; vx: number; vy: number; radius: number; target: Phaser.Math.Vector2 }
class NeighborGrid {
  private buckets = new Map<string, Agent[]>();
  constructor(private cellSize: number) {}
  rebuild(agents: Agent[]) {
    this.buckets.clear();
    for (const agent of agents) {
      const key = this.key(agent.x, agent.y);
      const list = this.buckets.get(key) ?? [];
      list.push(agent);
      this.buckets.set(key, list);
    }
  }
  nearby(x: number, y: number) {
    const cx = Math.floor(x / this.cellSize);
    const cy = Math.floor(y / this.cellSize);
    const out: Agent[] = [];
    for (let yy = cy - 1; yy <= cy + 1; yy++) for (let xx = cx - 1; xx <= cx + 1; xx++) out.push(...(this.buckets.get(`${xx},${yy}`) ?? []));
    return out;
  }
  private key(x: number, y: number) { return `${Math.floor(x / this.cellSize)},${Math.floor(y / this.cellSize)}`; }
}
function steer(agent: Agent, neighbors: Agent[]) {
  const desired = agent.target.clone().subtract(new Phaser.Math.Vector2(agent.x, agent.y)).normalize().scale(90);
  const avoid = new Phaser.Math.Vector2();
  for (const other of neighbors) if (other.id !== agent.id) {
    const dx = agent.x - other.x, dy = agent.y - other.y;
    const d2 = dx * dx + dy * dy;
    if (d2 > 0 && d2 < 42 * 42) avoid.add(new Phaser.Math.Vector2(dx, dy).normalize().scale(42 - Math.sqrt(d2)));
  }
  return desired.add(avoid.scale(3)).limit(110);
}

这段代码不是完整框架,而是把关键边界先立出来。实际项目里应继续补上配置加载、错误码、事件派发、性能统计和单元测试。只要骨架保持清楚,后续接入 Phaser 的 Graphics、Sprite、Matter、Tilemap 或 Sound 都不会污染规则层。

具体落地步骤

  1. 第一步,把 GlobalPath 和 LocalSteering 从 Scene 中拆出来,写成可以直接用 TypeScript 调用的模型。这个模型只接收普通对象,不接收 Sprite、Camera 或 Tween。只要这一步做到,后面的测试、调试、存档和工具预览都会简单很多。
  2. 第二步,在 Phaser Scene 里建立很薄的适配层。输入事件、物理回调、计时器和资源加载都可以在适配层发生,但它们只提交意图,不直接改核心状态。核心系统产出快照后,适配层再更新显示对象、音效、粒子和 HUD。
  3. 第三步,给每个关键状态准备调试可视化。不要等 QA 报问题才补日志。开发模式下至少能看到当前状态、最近输入、失败原因、候选列表、耗时和重要阈值。对复杂玩法来说,能看见中间状态比多写一层封装更重要。
  4. 第四步,用三类样例保护系统:正常流程、边界流程、错误配置。正常流程证明体验能跑通,边界流程证明快速输入、暂停、切场景和重复触发不会破坏状态,错误配置证明系统会给出明确报告,而不是静默失败。

项目检查清单

  • 确认 GlobalPath 的输入输出能被 JSON 记录,便于复现玩家操作。
  • 确认 LocalSteering 的配置有默认值、版本号和校验错误信息。
  • 确认快速点击、暂停、切后台、重开场景和读档不会重复提交关键状态。
  • 确认失败反馈比成功反馈更具体,玩家能理解自己为什么没有成功。
  • 确认低端机或高负载场景有降级策略,而不是等帧率下降后再猜瓶颈。
  • 确认调试面板能在不改代码的情况下打开,并能导出最近关键事件。

常见误区

第一类误区,是把 Phaser 的显示对象当成状态来源。显示对象适合表达结果,却不适合保存规则事实。它可能被对象池回收、被摄像机隐藏、被动画临时修改,也可能因为画质档变化而不存在。核心状态必须独立存在。

第二类误区,是只为当前关卡写逻辑。当前关卡对象少、节奏慢、输入简单,临时判断看起来没有问题。等到内容增加、节奏加快、平台变多,临时逻辑会互相覆盖。每个系统至少要提前考虑配置错误、重复触发和性能上限。

第三类误区,是没有把失败当成流程设计。复杂系统一定会失败:条件不满足、资源缺失、网络超时、玩家中断、配置非法。失败不应该只是 console 里的一行错误,而应该是玩家、QA 和内容团队都能理解的状态。

结语

群体移动与避让:拥堵、队列和低成本 AI 的难点不在某个 API,而在边界。把数据、规则、表现和调试分开后,Phaser 的优势会更明显:你可以很快做出反馈,也可以放心迭代规则。反过来,如果所有逻辑都散落在 Scene 的回调里,第一版越快,后续越难维护。

额外实践建议

  • 第一版可以没有高级算法,但必须先避免 O(n²) 邻居查询。
  • 把角色状态分成 walking、waiting、queued、blocked,拥堵问题会比单纯调速度容易处理。
  • 群体系统的性能预算要单独记录,因为它经常和动画、寻路、UI 同时在高峰出现。

继续阅读

探索更多技术文章

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

全部文章 返回首页