为什么值得单独做成系统
客厅双人合作游戏里,一个玩家用键盘,另一个玩家用手柄。两人靠近时共享一个镜头,分开探索时画面左右分屏。宝箱、敌人、任务提示和暂停菜单都要清楚知道自己属于谁。
本地合作最容易被低估。它不是把玩家复制一份,而是输入、镜头、UI、音频、存档和菜单都要支持多归属。Phaser 的 Camera 很灵活,但如果没有清晰的 PlayerSlot 模型,分屏会很快变成一堆特判。 本文会按一个可上线的小系统来拆,不追求炫技,而是把数据结构、状态流、玩家反馈、调试工具和发布检查说清楚。Phaser 的优势是让画面和交互快速成型,但越是快速,越需要把规则层和表现层分开。
核心架构
flowchart TD
N1["InputRouter"] --> N2["PlayerSlot"]
N2["PlayerSlot"] --> N3["CameraLayout"]
N3["CameraLayout"] --> N4["Phaser Cameras"]
N3["CameraLayout"] --> N5["ViewportSafeArea"]
N5["ViewportSafeArea"] --> N6["PlayerHUD"]
N7["PauseOwner"] --> N8["SceneCoordinator"]
N8["SceneCoordinator"] --> N2["PlayerSlot"]
这张图的重点是单向流动。PlayerSlot、InputRouter、CameraLayout、ViewportSafeArea、PlayerHUD、PauseOwner、SceneCoordinator 不应该互相随意读写。输入或场景事件进入模型,模型输出快照或事件,Phaser 表现层再根据结果更新 Sprite、Graphics、Sound 和 UI。只要这条边界稳定,后续加内容、加难度、加存档或加多人同步,都不会把系统推倒重写。
先定义玩家槽位
本地合作应先有 PlayerSlot,而不是直接创建 player1 和 player2。槽位记录玩家 id、设备、角色、镜头、HUD、是否在线、是否暂停。这样玩家掉线、换手柄、加入或退出都能围绕槽位处理。输入事件进入 InputRouter 后,先归属到槽位,再变成角色意图。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
动态合屏要有迟滞
两名玩家距离近时合屏,距离远时分屏。如果阈值只有一个,镜头会在临界距离反复切换。CameraLayout 应设置 splitDistance 和 mergeDistance,分屏后只有距离小于更低阈值才合回。切换时用短暂插值,避免画面突然跳变。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
每个镜头只渲染必要层
分屏时可以给每个玩家一个世界摄像机,再共享一个 UI 摄像机,或者每个玩家独立 HUD。关键是不要让玩家 1 的镜头渲染玩家 2 的私有提示。Phaser Camera ignore 列表和 layer 分组要提前规划,否则任务箭头、交互提示和名字牌会互相串台。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
输入设备需要重新绑定
手柄断开后重新连接,浏览器可能给不同 index。不要把 gamepad.index 永久绑定玩家。InputRouter 应支持设备签名、最近按键加入、手动换位和冲突提示。键盘双人也要处理按键冲突,不能假设所有玩家都能独占 WASD。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
HUD 安全区按视口计算
分屏后,每个玩家的血条、技能和提示都要落在自己的 viewport 里。不要用屏幕固定坐标写死 UI。ViewportSafeArea 根据 CameraLayout 输出每个槽位的矩形,HUD 只在这个矩形内布局。合屏时可以合并 HUD,也可以保留两边面板,规则要一致。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
暂停菜单要有主人
本地合作里谁按暂停,菜单焦点归谁?另一个玩家能不能继续移动?建议 PauseOwner 记录触发槽位,并冻结全局玩法,但菜单输入只接受暂停者,除非进入换手柄或设置页。这样不会出现两个人同时操作菜单导致焦点跳来跳去。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
调试要显示槽位映射
开发面板应显示每个 PlayerSlot 的设备、镜头矩形、HUD 根节点、输入事件计数和当前焦点。分屏问题常常不是镜头数学错误,而是输入归属或 UI 层级错误。把槽位信息画出来,测试双手柄时会省很多时间。
实现时建议先用最简单的调试图形验证规则,再接正式美术。比如先画出区域、方向、时间轴、占用格或检测范围,确认数据正确后再添加粒子、镜头、音效和过渡动画。这样做不花哨,但能避免很多“看起来对,规则其实错”的问题。
TypeScript 实现骨架
interface PlayerSlot { id: string; deviceId: string; camera: Phaser.Cameras.Scene2D.Camera; viewport: Phaser.Geom.Rectangle }
function layoutSplit(width: number, height: number, slots: PlayerSlot[]) {
if (slots.length === 1) {
slots[0].viewport.setTo(0, 0, width, height);
return;
}
const half = Math.floor(width / 2);
slots[0].viewport.setTo(0, 0, half, height);
slots[1].viewport.setTo(half, 0, width - half, height);
}
function applyCameraViewport(slot: PlayerSlot) {
const v = slot.viewport;
slot.camera.setViewport(v.x, v.y, v.width, v.height);
}
class InputRouter {
private deviceToSlot = new Map<string, string>();
bind(deviceId: string, slotId: string) { this.deviceToSlot.set(deviceId, slotId); }
route(deviceId: string, action: string) { return { slotId: this.deviceToSlot.get(deviceId), action }; }
}
这段代码只展示核心边界,不是完整项目代码。真实项目里还需要补配置加载、错误码、事件派发、对象池、性能采样和测试。关键是让核心规则能独立运行,Phaser 层只是把规则结果变成玩家能感知的反馈。
落地步骤
- 第一,先把 PlayerSlot 和 InputRouter 写成普通 TypeScript 模型。不要让它们依赖 Phaser Scene、Sprite 或 Camera。核心模型越普通,越容易写测试、做编辑器预览和复现玩家问题。
- 第二,Phaser 层只做适配:接收输入、播放动画、更新图形、触发音效。它可以很薄,但必须清楚。只要某段规则开始读取 Sprite 的 visible、alpha 或动画状态,就说明边界正在变脏。
- 第三,给 CameraLayout 或同等复杂的中间结果做调试显示。开发模式里能看到状态、阈值、候选对象、失败原因和耗时,后续调参才不会靠猜。
- 第四,准备三组测试夹具:正常流程、边界流程、错误配置。正常流程验证体验,边界流程验证稳定性,错误配置验证系统会报出人能看懂的问题。
检查清单
- 确认 PlayerSlot 的状态可以序列化,能写入存档或调试日志。
- 确认 InputRouter 的配置有默认值、版本号和校验错误。
- 确认快速点击、暂停、切后台、读档和切场景不会重复提交关键事件。
- 确认失败反馈足够具体,玩家能知道是条件不足、输入中断、资源不够还是规则禁止。
- 确认低端机有降级策略,尤其是粒子、音效、动态对象和调试图层。
- 确认开发模式可以导出最近关键事件,方便复现玩家反馈。
常见误区
第一类误区,是把表现当成事实。动画播完、按钮亮着、Sprite 存在,都只能说明表现层当前长什么样,不能说明规则已经完成。规则事实应该存在于模型和事件里。
第二类误区,是只为了第一个关卡写逻辑。第一个关卡对象少、输入慢、节奏简单,临时判断很难暴露问题。等内容增加,重复触发、配置错误和性能峰值会一起出现,早期的边界会决定后期成本。
第三类误区,是没有设计失败路径。复杂系统一定会遇到失败,好的失败路径会告诉玩家和开发者发生了什么;坏的失败路径只会留下一句操作失败,甚至什么都不显示。
发布前验证
发布前至少跑一次规则级测试和一次运行时冒烟。规则级测试不需要启动浏览器,直接喂数据,断言状态和事件。运行时冒烟则在 Phaser 场景里验证输入、反馈、暂停、重开和边界情况。若系统涉及经济、存档或排行榜,还要记录 requestId 或事件 id,保证重复提交不会造成重复奖励或重复扣费。
额外实践建议
- 先做固定左右分屏,再做动态合屏。
- 所有 UI 坐标都从 viewport 推导,不要使用全屏硬编码。
- 双人测试要覆盖断开手柄、重连、交换设备和同时暂停。
运行时观测与调参
本地合作最需要记录的是槽位状态变化,而不是单个角色状态。每次设备绑定、重连、换位、分屏切换、合屏恢复、暂停菜单打开和 UI 焦点变化,都应该留下简短事件。QA 测双手柄时,很多问题看起来像“镜头乱跳”,实际是某个设备被重新归到错误槽位;也有问题看起来像“按钮失灵”,实际是暂停菜单焦点还在另一个玩家身上。调试面板里把 PlayerSlot、deviceId、camera viewport 和当前菜单 owner 同时显示出来,定位速度会快很多。分屏阈值也应通过数据调:记录玩家分开距离分布,才能知道切屏是太频繁还是太迟钝。
结语
本地双人分屏:镜头、输入归属和 UI 安全区 的关键不是某个 API,而是把可解释的规则交给模型,把可感知的反馈交给 Phaser。只要这条线清楚,项目就能持续扩展;如果所有逻辑都塞进 Scene 回调,第一版越快,后面的维护压力越大。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。