Phaser RTS 框选与指令队列:从矩形选择到可撤销命令

讲解 Phaser RTS 客户端里的框选、多单位命中、右键指令、命令队列、队形移动、预测反馈和调试日志。

为什么要把它当成系统来做

一款俯视角科幻 RTS 原型里,玩家要同时操控矿车、步兵和维修无人机。鼠标拖出一个框,单位高亮;右键点击地面,队伍分散移动;按住 Shift,后续指令进入队列而不是覆盖当前任务。

这套体验看似只是输入层问题,实际牵涉选择集合、单位状态机、寻路、队形、取消、网络同步和 UI 反馈。若每个单位自己处理右键,第一版能动,第二版就会失控。 本文不把它写成一个一次性 Demo,而是按可上线、可维护的小系统拆开。重点不是堆 API,而是回答几个真实问题:数据从哪里来,谁有权修改状态,失败时玩家看到什么,调试时程序能看到什么,内容增加后系统还能不能承受。

核心架构

flowchart TD
  N506f696e74["Pointer Events"] --> N496e707574["InputRouter"]
  N496e707574["InputRouter"] --> N53656c6563["SelectionSet"]
  N53656c6563["SelectionSet"] --> N436f6d6d61["CommandFactory"]
  N436f6d6d61["CommandFactory"] --> N436f6d6d61["CommandQueue"]
  N436f6d6d61["CommandQueue"] --> N466f726d61["FormationSolver"]
  N466f726d61["FormationSolver"] --> N556e697453["UnitStateMachine"]
  N556e697453["UnitStateMachine"] --> N5068617365["Phaser 表现反馈"]

这张图的关键,是把 InputRouter、SelectionSet、CommandFactory、CommandQueue、FormationSolver、UnitStateMachine 放在单向流里。玩家输入或系统 tick 进入核心模型,模型产出结果,Phaser 再把结果转成动画、粒子、声音和界面。不要让显示对象反向决定规则。只要核心模型能在没有 Canvas 的环境中运行,就能写测试、做编辑器预览,也能在以后接入服务端校验或云存档。

框选先处理空间索引

几十个单位可以直接遍历 Sprite,几百个单位就应该有空间索引。RTS 框选每一帧都可能变化,如果用 getBounds 对所有单位做矩形相交,低端设备会抖。更稳的做法是维护单位的逻辑位置和屏幕包围盒,拖拽时先用四叉树或网格桶筛出候选,再做精确判断。Phaser 的显示对象只作为最终高亮目标。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

选择集合要有版本号

玩家框选、追加选择、双击选择同类、控制编队都会修改选择集合。给 SelectionSet 加 version,可以避免 UI 面板和命令层读取到半旧半新的状态。每次集合变化都发出 selectionChanged(version, ids),头像栏、血条汇总和指令面板都通过同一个事件刷新。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

命令不是函数调用

右键地面不应该直接让单位 moveTo。更合理的是创建 MoveCommand,记录目标点、队形、是否追加、下发时间和来源输入。攻击、修理、采集也是命令对象。这样才能撤销、重放、记录日志,也能在多人模式里把同一条命令发给同步层。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

队形移动要先分配槽位

多个单位去同一个点,如果直接寻路到同一点,会堆成一团。FormationSolver 可以根据单位数量和碰撞半径生成若干槽位,再按单位当前位置到槽位距离做贪心或匈牙利分配。第一版不用过度复杂,但必须保证槽位稳定,否则单位会在路上来回交换目标。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

Shift 队列要能被看见

按住 Shift 追加指令时,玩家需要看到队列预览:当前任务之后还有几个路点,攻击命令排在哪个位置。队列可以用小旗帜、连线和编号展示。若队列太长,只显示前几项和省略标记。关键是让玩家知道操作已被接受,而不是等单位执行到那一步才发现输入无效。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

取消和覆盖必须统一

普通右键覆盖队列,Shift 右键追加队列,Stop 清空队列并进入 Idle,Hold Position 清空移动但保留防守规则。这些操作容易散落在 UI、快捷键和场景点击里。建议把它们都变成 CommandQueue 的方法,输入层只描述意图。统一之后,教程、回放和快捷键也能复用。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

预测反馈不能承诺过度

客户端可以立即画出移动虚线和目标圈,但不要假装寻路一定成功。对于被战争迷雾遮挡、临时堵路或服务端权威的项目,反馈应区分已接受、等待确认和失败回滚。即使是单机,也建议保留 commandId,后续日志能定位玩家投诉中的具体操作。

在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。

TypeScript 实现骨架

type CommandKind = "move" | "attack" | "repair" | "stop";
interface Command { id: string; kind: CommandKind; append: boolean; issuedAt: number }
interface MoveCommand extends Command { kind: "move"; target: Phaser.Math.Vector2; formation: "line" | "wedge" }
class UnitCommandQueue {
  private queue: Command[] = [];
  issue(command: Command) {
    if (!command.append) this.queue.length = 0;
    if (command.kind === "stop") { this.queue.length = 0; return; }
    this.queue.push(command);
  }
  peek() { return this.queue[0]; }
  completeCurrent() { this.queue.shift(); }
  snapshot() { return this.queue.map(c => ({ id: c.id, kind: c.kind })); }
}
function buildMoveCommands(ids: string[], target: Phaser.Math.Vector2, append: boolean): MoveCommand[] {
  return ids.map((id, index) => ({
    id: `cmd-${Date.now()}-${id}`,
    kind: "move", append, issuedAt: performance.now(),
    target: target.clone().add(new Phaser.Math.Vector2((index % 4) * 28, Math.floor(index / 4) * 28)),
    formation: "line",
  }));
}

这段代码只是骨架,真正项目里还要加事件派发、错误码、配置校验和日志。但骨架已经表达了方向:核心概念是普通 TypeScript 对象,Phaser 类型只出现在输入适配或表现需要的地方。若你发现某个函数越来越依赖 Scene、Camera 或 Sprite,就应该停下来判断它是不是被放错层了。

落地步骤

  1. 第一,确认 InputRouter 的输入输出是否是纯数据。若需要 Phaser.GameObjects 才能计算结果,说明边界还没有切开。
  2. 第二,给 SelectionSet 或同等级的核心概念写三个最小样例:正常路径、边界路径、失败路径。样例要能在没有浏览器画面的情况下运行。
  3. 第三,把 UI 上每个可点击动作都映射成明确意图,不要让按钮直接修改深层状态。意图里带 requestId,便于防重复和追踪。
  4. 第四,失败反馈要比成功反馈更早接入。成功时玩家通常愿意接受,失败时才会质疑系统是否可靠。
  5. 第五,内容配置要有默认值和校验脚本。字段缺失时宁可在启动时报错,也不要在玩家操作到一半才静默失败。
  6. 第六,性能指标要提前量化:每帧最多处理多少对象、单次刷新允许多少毫秒、低端机是否需要降级显示。

常见坑

  • 最容易踩的坑,是让表现层过早成为事实来源。比如动画播完才算成功、按钮亮着就代表可用、某个 Sprite 存在就说明状态存在。这些判断在演示机上没问题,一到跳过、暂停、断线、切场景和重连就会变成隐性故障。
  • 第二个坑是只为第一关写逻辑。第一关对象少、路径短、输入慢,任何写法都像是正确的。等内容增加到几十张地图、几百个配置和各种活动修正时,临时判断会互相覆盖。写系统时要假设它会被复用、被误用、被配置错。
  • 第三个坑是没有留下证据。玩家反馈“刚才没生效”时,如果没有事件日志、状态快照或 requestId,只能靠猜。哪怕是单机项目,也可以保留最近 50 条关键事件,开发包里导出文本,定位速度会快很多。

项目里的验证方式

把这套系统放进 Phaser 项目时,我会先建一个不依赖 Scene 的核心目录,例如 src/gameplay/rts-selection-command。里面只放模型、求解器、状态机和测试夹具。Scene 只负责输入适配、对象池、摄像机、音效和 UI。这个边界看似多写几行代码,但它换来的是可测试、可回放和可迁移。等项目进入内容生产阶段,最值钱的不是某个特效多漂亮,而是当策划说某个状态不对时,程序能在五分钟内复现并解释。

数据格式要尽量像内容团队会填写的表,而不是像程序临时拼出来的对象。每个 id 都要稳定,每个状态都要能序列化,每个失败原因都要有明确枚举。Phaser 的优势是快速把反馈做出来,但反馈越快,越容易掩盖规则层的混乱。先把规则层写清楚,再接动画,后续加新模式、新活动或新平台才不会反复拆墙。

在调试阶段,建议给这个系统加一个小面板:显示当前输入、核心状态、最近事件、最后一次失败原因和关键耗时。面板不需要好看,但要准确。很多客户端问题在日志里只是一句 undefined,在调试面板里却能看到完整链路。尤其是多人、存档、复杂 UI 或长时间运行的玩法,调试可见性直接决定维护成本。

最后检查

做完第一版后,不要只看一次演示是否顺滑。至少准备三组数据:一组正常流程,一组边界流程,一组故意配置错误的流程。正常流程证明体验成立,边界流程证明状态不会漂移,错误流程证明系统会给出可理解的失败原因。RTS 框选与指令队列:从矩形选择到可撤销命令 的质量不取决于第一眼多热闹,而取决于玩家反复操作、内容不断扩张、版本持续迭代时,它还能保持清楚、稳定和可解释。

继续阅读

探索更多技术文章

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

全部文章 返回首页