为什么要把它当成系统来做
一款动作 Roguelite 想让法师不只是点按钮。玩家按住鼠标或触屏,在角色身边画一个闪电形状释放链雷,画圆释放护盾,画短斜线释放冲刺。系统要有仪式感,也要能容错。
手势系统很容易变成炫技功能:演示时惊艳,实战中误判。要做得可靠,必须把采样、归一化、模板匹配、法术状态和失败反馈拆开。 本文不把它写成一个一次性 Demo,而是按可上线、可维护的小系统拆开。重点不是堆 API,而是回答几个真实问题:数据从哪里来,谁有权修改状态,失败时玩家看到什么,调试时程序能看到什么,内容增加后系统还能不能承受。
核心架构
flowchart TD
N506f696e74["Pointer Drag"] --> N5374726f6b["StrokeSampler"]
N5374726f6b["StrokeSampler"] --> Ne8bda8e8bf["轨迹点"]
Ne8bda8e8bf["轨迹点"] --> N4e6f726d61["Normalizer"]
N4e6f726d61["Normalizer"] --> N54656d706c["TemplateMatcher"]
N54656d706c["TemplateMatcher"] --> Ne58099e980["候选法术"]
Ne58099e980["候选法术"] --> N5370656c6c["SpellResolver"]
N5370656c6c["SpellResolver"] --> N5068617365["Phaser 粒子与冷却 UI"]
这张图的关键,是把 GestureInput、StrokeSampler、Normalizer、TemplateMatcher、SpellResolver、CastFeedback 放在单向流里。玩家输入或系统 tick 进入核心模型,模型产出结果,Phaser 再把结果转成动画、粒子、声音和界面。不要让显示对象反向决定规则。只要核心模型能在没有 Canvas 的环境中运行,就能写测试、做编辑器预览,也能在以后接入服务端校验或云存档。
采样要抹平设备差异
鼠标、触控笔和手指产生的点密度不同。直接拿原始 pointermove 轨迹做识别,会让高刷新设备更容易匹配复杂模板。建议按距离重采样,例如每隔 8 像素取一个点,再统一缩放到标准盒子。采样层只处理几何,不关心法术。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
识别不应该追求绝对精确
战斗中玩家会紧张,手势会歪。模板匹配应给出置信度,而不是非黑即白。可以用简单的一美元识别器思想:重采样、旋转归一、缩放、平移,然后计算与模板的平均距离。若第一名和第二名差距太小,就提示手势模糊,而不是随机释放一个法术。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
施法状态要限制输入窗口
玩家被击退、眩晕、正在翻滚或冷却未完成时,输入层仍可能采到轨迹。SpellResolver 必须检查角色状态、法力、冷却和场景禁用条件。不能只靠按钮灰掉,因为手势输入通常覆盖在整个屏幕上。失败原因要反馈给 UI,比如轨迹变灰、法阵碎裂或播放短音效。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
视觉反馈要跟随轨迹但不决定结果
轨迹可以用 Graphics 或 RenderTexture 画出带衰减的光痕,采样点越新越亮。识别结果出来前,画面只表现正在聚能;识别成功后才生成法术预览。不要让粒子碰到敌人就造成伤害,伤害必须由 SpellResolver 统一发出。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
移动端要处理误触
手势系统和移动摇杆、相机拖动很容易冲突。可以设定施法区域、长按阈值和最短轨迹长度。若玩家只点了一下,视为普通攻击;轨迹太短或跨过 UI 面板,直接取消。多指操作时,只接受第一根进入施法区的手指,并锁定 pointerId。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
模板要能由策划维护
每个法术的数据包括模板点、最短长度、置信度阈值、冷却、法力、失败提示和教学示意。模板可以在开发工具里录制,不要手写坐标。录制后保存归一化点集,运行时只加载轻量数据。新增法术不应修改识别器代码。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
失败反馈比成功特效更重要
玩家画错时要知道是太短、太慢、形状不清还是当前不能施法。失败反馈若只是一句红字,手感会很差。可以让轨迹颜色从蓝变橙,法阵碎成小粒子,角色手部动画中断。反馈要短,不要挡住战斗,也不要惩罚玩家太久。
在实现这一点时,建议先写一组可以复现的输入数据,再接 Phaser 场景。先让控制台打印出正确状态,再让 Graphics、Sprite、Text 和 Tween 去表现它。这样的顺序不炫,但能减少非常多“画面看起来对,规则其实错”的问题。
TypeScript 实现骨架
interface Point { x: number; y: number }
interface GestureTemplate { id: string; points: Point[]; threshold: number }
function resample(points: Point[], step = 8) {
const out: Point[] = [points[0]];
let carry = 0;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1], b = points[i];
const d = Phaser.Math.Distance.Between(a.x, a.y, b.x, b.y);
carry += d;
if (carry >= step) { out.push(b); carry = 0; }
}
return out;
}
function scoreGesture(input: Point[], tpl: GestureTemplate) {
const n = Math.min(input.length, tpl.points.length);
if (n < 6) return Number.POSITIVE_INFINITY;
let sum = 0;
for (let i = 0; i < n; i++) sum += Phaser.Math.Distance.Between(input[i].x, input[i].y, tpl.points[i].x, tpl.points[i].y);
return sum / n;
}
function matchGesture(points: Point[], templates: GestureTemplate[]) {
const sampled = resample(points);
return templates
.map(t => ({ id: t.id, score: scoreGesture(sampled, t), ok: scoreGesture(sampled, t) <= t.threshold }))
.sort((a, b) => a.score - b.score)[0];
}
这段代码只是骨架,真正项目里还要加事件派发、错误码、配置校验和日志。但骨架已经表达了方向:核心概念是普通 TypeScript 对象,Phaser 类型只出现在输入适配或表现需要的地方。若你发现某个函数越来越依赖 Scene、Camera 或 Sprite,就应该停下来判断它是不是被放错层了。
落地步骤
- 第一,确认 GestureInput 的输入输出是否是纯数据。若需要 Phaser.GameObjects 才能计算结果,说明边界还没有切开。
- 第二,给 StrokeSampler 或同等级的核心概念写三个最小样例:正常路径、边界路径、失败路径。样例要能在没有浏览器画面的情况下运行。
- 第三,把 UI 上每个可点击动作都映射成明确意图,不要让按钮直接修改深层状态。意图里带 requestId,便于防重复和追踪。
- 第四,失败反馈要比成功反馈更早接入。成功时玩家通常愿意接受,失败时才会质疑系统是否可靠。
- 第五,内容配置要有默认值和校验脚本。字段缺失时宁可在启动时报错,也不要在玩家操作到一半才静默失败。
- 第六,性能指标要提前量化:每帧最多处理多少对象、单次刷新允许多少毫秒、低端机是否需要降级显示。
常见坑
- 最容易踩的坑,是让表现层过早成为事实来源。比如动画播完才算成功、按钮亮着就代表可用、某个 Sprite 存在就说明状态存在。这些判断在演示机上没问题,一到跳过、暂停、断线、切场景和重连就会变成隐性故障。
- 第二个坑是只为第一关写逻辑。第一关对象少、路径短、输入慢,任何写法都像是正确的。等内容增加到几十张地图、几百个配置和各种活动修正时,临时判断会互相覆盖。写系统时要假设它会被复用、被误用、被配置错。
- 第三个坑是没有留下证据。玩家反馈“刚才没生效”时,如果没有事件日志、状态快照或 requestId,只能靠猜。哪怕是单机项目,也可以保留最近 50 条关键事件,开发包里导出文本,定位速度会快很多。
项目里的验证方式
把这套系统放进 Phaser 项目时,我会先建一个不依赖 Scene 的核心目录,例如 src/gameplay/gesture-drawing-spellcasting。里面只放模型、求解器、状态机和测试夹具。Scene 只负责输入适配、对象池、摄像机、音效和 UI。这个边界看似多写几行代码,但它换来的是可测试、可回放和可迁移。等项目进入内容生产阶段,最值钱的不是某个特效多漂亮,而是当策划说某个状态不对时,程序能在五分钟内复现并解释。
数据格式要尽量像内容团队会填写的表,而不是像程序临时拼出来的对象。每个 id 都要稳定,每个状态都要能序列化,每个失败原因都要有明确枚举。Phaser 的优势是快速把反馈做出来,但反馈越快,越容易掩盖规则层的混乱。先把规则层写清楚,再接动画,后续加新模式、新活动或新平台才不会反复拆墙。
在调试阶段,建议给这个系统加一个小面板:显示当前输入、核心状态、最近事件、最后一次失败原因和关键耗时。面板不需要好看,但要准确。很多客户端问题在日志里只是一句 undefined,在调试面板里却能看到完整链路。尤其是多人、存档、复杂 UI 或长时间运行的玩法,调试可见性直接决定维护成本。
最后检查
做完第一版后,不要只看一次演示是否顺滑。至少准备三组数据:一组正常流程,一组边界流程,一组故意配置错误的流程。正常流程证明体验成立,边界流程证明状态不会漂移,错误流程证明系统会给出可理解的失败原因。手势绘制施法:轨迹采样、容错识别和失败反馈 的质量不取决于第一眼多热闹,而取决于玩家反复操作、内容不断扩张、版本持续迭代时,它还能保持清楚、稳定和可解释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。