卡牌拖拽的难点在出牌判定
Phaser 做卡牌战斗很直观:卡牌是 Sprite 或 Container,手牌区是一排对象,拖到敌人身上释放,就触发技能。原型阶段这样写很快,但上线项目很快会遇到更多规则:费用不够不能出,沉默状态不能出,某些牌必须选友方,某些牌不需要目标,拖到空地要取消,动画播放期间不能连点,服务端否定后要回滚。
我做过一个 Roguelike 卡牌 H5,第一版拖拽逻辑写在 CardView 里。CardView 自己判断费用,自己找目标,自己扣能量,自己播放动画。后来加了冻结、复制、临时费用、随机目标和服务端校验后,CardView 成了全项目最危险的类。一次 UI 重构甚至改坏了出牌费用。
卡牌系统要把“拖拽表现”和“出牌规则”分开。Phaser 负责让卡牌被拿起、移动、吸附和回弹;规则系统负责判断这张牌能不能出、需要什么目标、出牌后产生什么效果。拖拽只是提交出牌意图的其中一种交互方式。
flowchart TD
A[玩家拖拽卡牌] --> B[DragLayer 更新表现]
B --> C{释放位置}
C -->|目标区域| D[生成 PlayCardIntent]
C -->|无效区域| E[卡牌回到手牌]
D --> F[规则服务校验费用/状态/目标]
F -->|通过| G[锁定手牌并播放出牌动画]
F -->|失败| H[显示原因并回弹]
G --> I[结算效果/等待权威确认]
I --> J[刷新手牌和战场]
手牌区是布局系统
很多卡牌项目把每张牌的位置写死,抽牌时追加到数组末尾,弃牌时重新排一排。手牌数量少时没问题,后面加入临时牌、保留牌、放大预览、拖拽占位、排序和动画后,手牌区就需要成为一个小型布局系统。
手牌区应该知道最大手牌数、卡牌间距、扇形角度、当前拖拽卡牌、占位位置和屏幕安全区。它负责把逻辑手牌列表排成 Phaser 坐标。卡牌本身不应该决定自己在手牌第几个位置,最多只知道自己正在被拖拽或悬停。
移动端尤其要注意手牌区高度。卡牌太低会被系统手势区域挡住,太高会挡住战场。拖拽时可以把卡牌放大并移到上层 DragLayer,释放后再回到 HandLayer。层级管理比简单 setDepth 更重要。
目标选择要用语义区域
卡牌目标类型不同:敌方单体、友方单体、全体敌人、全体友方、无目标、地面格、随机目标。不要让 CardView 通过碰撞敌人 sprite 自己决定目标。应由 TargetResolver 根据卡牌配置和释放位置返回目标结果。
目标区域也要考虑视觉和规则差异。一个 Boss 立绘很大,但可选区域可能只在身体中部;一个召唤物很小,手机上很难点中,需要扩大命中区域。命中区域是输入容错,不等于碰撞盒或图片边界。可以给每个战斗单位一个 target hit area,供卡牌选择使用。
释放到无效区域时,要明确反馈。费用不够、目标类型不对、状态禁止出牌、回合不允许操作,都应该给不同提示。不要全部只是卡牌弹回。玩家需要知道是自己拖错了,还是当前不能出。
费用校验必须集中
费用系统看起来简单:当前能量大于卡牌费用就能出。但真实项目会有临时费用、费用减免、费用增加、复制牌、保留牌、消耗牌、沉默、禁用、连击加成和回合外出牌。费用校验如果散在 UI 里,很快会和结算不一致。
建议有一个 PlayCardService,输入是 cardId、sourceZone、targetId 和当前战斗状态,输出是可出牌或失败原因。UI 在拖拽过程中可以调用轻量预校验显示高亮,释放时再调用正式校验。正式校验通过后,才锁定卡牌并进入结算。
服务端权威项目里,客户端预校验只用于体验,最终仍要等服务端确认。客户端可以先播放出牌动画,但能量、手牌、伤害和奖励要能在服务端否定时回滚。单机项目也建议保留同样接口,方便以后接入权威逻辑。
出牌动画和结算要排队
卡牌战斗很容易出现动画和状态不同步。玩家拖出攻击牌,卡牌飞向敌人,敌人受击,数字飘出,卡牌进弃牌堆,抽新牌。如果这些步骤全靠回调串起来,稍微加速、跳过或连点就会乱。
可以把出牌过程拆成动作队列:锁输入、卡牌飞出、播放施法、应用效果、播放受击、移动到弃牌区、刷新 UI、解锁输入。每个动作有完成信号。这样动画速度变化、跳过和回放都更可控。
结算也要区分规则和表现。规则可以瞬间计算出敌人掉血和状态变化,表现按队列逐步播放。若表现过程中玩家切后台,恢复时可以选择继续动画或直接应用最终状态。不要让规则结果依赖动画是否完整播完。
拖拽手感要有吸附和取消
好的卡牌拖拽不是简单跟随 pointer。拿起时卡牌应放大、抬高层级、从手牌布局中脱离;靠近合法目标时目标高亮;靠近战场空区时显示释放预览;回到手牌区或按取消时回弹。手机上,手指会遮挡卡牌,拖拽对象可以略微高于手指位置。
拖拽过程中不要频繁修改逻辑手牌。逻辑手牌在确认出牌前不变,手牌区只显示一个临时占位。这样拖到一半取消时,状态恢复很简单。确认出牌后,再从手牌 zone 移到 pending 或 discard。
多点触控要限制。卡牌游戏通常一次只允许拖一张牌。第二根手指可以忽略或用于取消,但不要让两张牌同时进入拖拽状态。页面失焦、pointercancel、弹窗打开时,都要取消当前拖拽并恢复手牌布局。
一个出牌意图接口
下面示例展示出牌服务的入口。UI 不直接扣费,只提交意图。
type PlayCardIntent = {
cardInstanceId: string;
targetId?: string;
source: 'hand' | 'created' | 'discard';
};
function validatePlay(intent: PlayCardIntent, battle: BattleState): PlayCardResult {
const card = battle.cards[intent.cardInstanceId];
if (!card) return { ok: false, reason: 'missing-card' };
if (battle.energy < card.cost) return { ok: false, reason: 'not-enough-energy' };
if (!isValidTarget(card, intent.targetId, battle)) return { ok: false, reason: 'invalid-target' };
return { ok: true, effects: buildEffects(card, intent.targetId) };
}
这段代码不是完整战斗系统,但表达了边界:卡牌视图只负责交互,规则服务负责校验和生成效果。后续加入费用减免、状态限制和服务端确认时,入口仍然稳定。
调试要能回放一回合
卡牌战斗的 bug 经常和顺序有关。某张牌复制后费用不对,某个状态在回合结束前消失,某个目标死亡后仍被后续效果命中。开发版最好能记录一回合内的操作和效果序列:抽牌、出牌、目标、费用、效果、状态变化、弃牌。
还可以提供“重放上一回合”按钮,用同样的随机种子和操作序列重建问题。卡牌游戏很依赖随机,抽牌顺序、随机目标和随机奖励都要记录 seed。没有 seed,很多问题只能靠反复试。
QA 提交问题时,最好附上卡组、回合数、手牌列表、能量、敌人状态和操作序列。调试工具可以自动导出这些信息,不要让测试手工描述“先出了那张红色牌,然后又出了一张蓝色牌”。
上线前检查清单
上线前检查:拖拽取消是否恢复手牌,费用校验是否集中,目标区域是否清楚,移动端手指遮挡是否处理,动画队列是否能跳过,服务端否定是否能回滚,页面失焦是否取消拖拽,随机结果是否可复盘,失败原因是否可读。
还要测试极端情况:手牌满时抽牌、目标在出牌动画中死亡、费用在拖拽过程中变化、拖拽时打开弹窗、网络延迟下连续出牌、复制牌和原牌同时存在。卡牌系统的复杂度不是卡面数量,而是状态组合。
牌面 UI 要显示规则来源
卡牌游戏里,玩家最关心的是“为什么这张牌现在是 1 费”“为什么它不能打这个目标”。如果 UI 只显示最终费用和灰态,玩家会觉得规则不透明。更好的方式是在牌面或长按详情里显示费用来源:基础 2 费,本回合减 1,敌方光环加 1,最终 2 费。状态限制也要有原因。
Phaser 牌面可以做成 Container:底图、费用、名称、描述、标签、状态遮罩。描述文本不要手写最终字符串,而是由卡牌模板、当前修正和本地化参数生成。这样费用变化、伤害变化、关键词变化都能刷新。否则玩家看到描述写“造成 6 点伤害”,实际因为力量加成造成 9 点,就会产生不信任。
动态描述还要注意性能。不要每帧重排所有手牌文本。只有战斗状态变化、抽牌、费用变化或语言切换时才刷新。卡牌 UI 很密集,文本测量和换行如果太频繁,会在移动端造成卡顿。
卡牌效果要可预览但不越权
玩家拖动一张攻击牌到敌人身上时,可以预览预计伤害、破甲、击杀标记和连锁效果。这个预览非常有用,但它只是根据当前客户端状态计算的预测。真正结算仍然由规则服务或服务端确认。预览和结算必须使用同一套规则入口,否则预览命中,结算未命中,会让体验很差。
预览也要处理随机。随机目标、随机伤害、抽牌类效果不应该显示确定结果,可以显示范围或说明。例如“随机造成 3 到 6 点伤害”“抽 2 张牌”。如果客户端为了好看提前随机一个结果,后续服务端结果不同,就会重演抽卡动画那类信任事故。
对于复杂连锁,可以只预览第一层结果。比如击杀后触发额外攻击,预览可以显示“可能触发连击”,不必把整条链都算完。可操作性和信息量要平衡,卡牌游戏最怕 UI 把玩家淹没。
卡组和弃牌堆也要可视化
卡牌战斗不只有手牌。抽牌堆、弃牌堆、消耗区、临时区、生成牌区都会影响玩家决策。Phaser UI 至少要让玩家查看抽牌堆数量、弃牌堆列表和已消耗卡牌。Roguelike 卡牌尤其需要透明,玩家要知道下一轮大概还有什么资源。
这些区域同样不能只是 UI 数组。抽牌、洗牌、弃牌、消耗都应该走 ZoneService。每张卡牌实例当前在哪个 zone,应当可追踪。动画上,卡牌从手牌飞到弃牌堆只是表现;规则上,它的 zone 已经从 hand 变成 discard。表现失败时,zone 仍要正确。
洗牌是常见问题点。弃牌堆洗回抽牌堆时,要记录随机 seed 和顺序。玩家反馈抽牌异常时,工程可以复盘。若服务端权威,洗牌结果应由服务端给出,客户端只播放洗牌动画。
卡牌区的可视化还能帮助新手理解规则。很多玩家第一次接触构筑类玩法时,不知道弃牌堆何时洗回抽牌堆,也不知道消耗区和弃牌堆的区别。UI 把这些区域做清楚,本身就是教学的一部分。
如果要做自动战斗或回放,卡牌 zone 更是核心数据。只要每张牌的位置和移动原因可追踪,一回合的行为就能被稳定重放。
卡牌系统上线前还要做“禁用路径”测试。玩家被眩晕、回合结束、动画播放、网络等待、弹窗打开时,所有出牌入口都应该被同一把锁控制。不要只禁用拖拽,快捷键或双击仍然能出牌。操作锁要有 reason,调试面板能看到当前为什么不能出牌。
这能避免大量“明明灰了还能出”的边界问题。
结语
Phaser 做卡牌战斗,难点不是把卡牌拖起来,而是让出牌意图、规则校验、目标选择和动画结算形成闭环。UI 需要顺滑,规则需要集中,失败需要可解释,随机需要可复盘。
把 CardView 从规则里解放出来,卡牌系统才会越做越稳。否则每一张新牌都会把 UI、战斗和存档搅在一起,最终让一次简单的拖拽变成最难维护的功能。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。