三消最难的是“连锁之后还说得清”
三消原型看起来简单:交换两个棋子,找到三连,消除,棋子往下掉,再补新棋子。真正做成游戏后,复杂度会不断增加:四连生成横向炸弹,五连生成彩虹球,T 型生成范围炸弹,连锁有倍率,道具可以打断棋盘,目标任务要统计指定颜色,关卡需要保证初始棋盘无自动消除且至少有一步可走。若所有逻辑都写在 Phaser 动画回调里,连锁一多就会变成不可复现的混乱。
三消的核心应该是棋盘模型和结算流水线。Phaser 只负责把每一步结果可视化:交换动画、消除动画、掉落动画、生成动画、分数飞字。模型层先计算出一个或多个结算阶段,动画层按队列播放。这样才能保证同一个棋盘和随机种子得到同样结果,也能为关卡验证、回放和自动测试打基础。
棋盘模型不要依赖 Sprite
每个格子应该是纯数据:坐标、棋子类型、特殊类型、锁链、冰块、传送门、障碍等。Sprite 是格子的表现,不是状态来源。消除时先改模型,再生成视觉事件;动画结束后只是确认表现完成。不要让 Sprite 的 y 坐标决定它在哪一格,因为掉落动画中间位置会不断变化,逻辑会被动画污染。
棋盘坐标也要统一。建议用 row/col 表示逻辑位置,用 helper 转换为世界坐标。所有匹配检测都基于 row/col。特殊棋子影响范围也基于坐标集合,而不是屏幕矩形。这样棋盘缩放、居中、横竖屏适配都不会影响规则。
flowchart TD
A["玩家交换两个格子"] --> B["BoardModel 验证交换是否合法"]
B --> C["MatchFinder 找出所有匹配"]
C --> D["Resolver 生成消除和特殊棋子结果"]
D --> E["GravitySolver 计算掉落和补位"]
E --> F["AnimationQueue 顺序播放交换、消除、掉落、生成"]
F --> G{"是否产生新匹配"}
G -- "是" --> C
G -- "否" --> H["回合结束:统计目标、检查胜负"]
匹配检测要处理重复和交叉
最基础的匹配检测是横向扫描和纵向扫描,找到长度大于等于 3 的连续同色棋子。问题在于交叉匹配:一个 T 型会被横向和纵向各找到一组,中心棋子不能重复消除两次,但它可能决定生成什么特殊棋子。检测结果最好先输出一组 match segments,再合并为消除集合,并保留形状信息。这样四连、五连、T 型和 L 型可以按规则生成特殊棋子。
匹配检测还要排除障碍、空格、锁定棋子和不可匹配棋子。有些棋子可以被消除但不参与匹配,有些棋子需要邻近消除才破坏。规则要写在 tile type 或 piece type 配置里,不要在扫描循环中散落特殊判断。
掉落补位是确定性的
消除后,棋子掉落和新棋子生成必须可复现。每一列从下往上压实,空位从顶部补新棋子,这是最常见规则。若有斜向掉落、传送门、重力方向变化,GravitySolver 会更复杂,但仍应输出明确的 move events。新棋子生成使用注入的随机源,不能直接 Math.random()。关卡失败、回放、排行榜和客服复现都依赖确定性。
初始棋盘生成也要避免自动匹配,并保证至少有一步可走。生成器可以逐格填充时避免形成三连,然后用 MoveFinder 检查是否存在合法交换。若没有可走步,可以重洗或局部替换。重洗要播放动画并保留关卡目标,不要像 bug 一样突然换棋盘。
动画队列不能决定规则
三消动画很多:交换、回弹、爆炸、掉落、补位、连锁提示。若规则等待每个 tween 回调再继续计算,会出现竞态:玩家切后台、动画被跳过、低帧率导致回调顺序变化。更稳的方式是模型一次结算一个阶段,生成 visual events,AnimationQueue 播放完这批事件后通知进入下一阶段。动画可以快进或关闭,但模型结果不变。
输入锁定也要由结算状态控制。连锁过程中不允许玩家再次交换,但可以允许点击道具队列等待。若玩家使用锤子道具打断当前连锁,必须定义清楚是在当前阶段后执行,还是立即插入。三消看起来休闲,状态机却不能随便。
一个基础匹配检测器
下面的代码只展示横纵扫描和去重。真实项目还会附带形状识别和特殊棋子生成。
type PieceKind = "red" | "blue" | "green" | "yellow" | "purple";
interface Cell { row: number; col: number; kind?: PieceKind; blocked?: boolean }
export function findMatches(grid: Cell[][]) {
const matched = new Map<string, Cell>();
const addRun = (run: Cell[]) => {
if (run.length >= 3) {
for (const cell of run) matched.set(`${cell.row},${cell.col}`, cell);
}
};
for (const row of grid) {
let run: Cell[] = [];
for (const cell of row) {
const last = run[0];
if (!cell.blocked && cell.kind && last?.kind === cell.kind) run.push(cell);
else {
addRun(run);
run = !cell.blocked && cell.kind ? [cell] : [];
}
}
addRun(run);
}
for (let col = 0; col < grid[0].length; col++) {
let run: Cell[] = [];
for (let row = 0; row < grid.length; row++) {
const cell = grid[row][col];
const last = run[0];
if (!cell.blocked && cell.kind && last?.kind === cell.kind) run.push(cell);
else {
addRun(run);
run = !cell.blocked && cell.kind ? [cell] : [];
}
}
addRun(run);
}
return [...matched.values()];
}
这段函数返回消除集合,不关心 Phaser。你可以给它喂棋盘测试横向、纵向、交叉和障碍场景。等规则稳定后,再把结果交给动画层。
特殊棋子要有触发顺序
特殊棋子会让三消复杂度翻倍。横向炸弹、纵向炸弹、范围炸弹、彩虹球、同色清除,两两组合时还会产生更强效果。必须规定触发顺序:先处理玩家交换形成的特殊棋子,还是先处理普通匹配?两个特殊棋子交换时是否直接触发组合?连锁中生成的特殊棋子是否立刻触发,还是等下一次被消除?这些规则都要写清楚。
建议把特殊效果也变成 resolver。输入是棋盘和触发源,输出是受影响格子集合和 visual events。不要在每个特殊棋子 Sprite 上写爆炸逻辑。这样组合效果可以被测试,也能避免同一个格子被多次扣目标或播放多次爆炸。
分数、目标和连击倍率
连锁结算时,分数和目标统计要和阶段绑定。第一波消除倍率 1,第二波 1.5,第三波 2,这些都应由 ComboResolver 管理。目标任务,比如消除 20 个蓝色、打碎 10 个冰块、收集 3 个钥匙,也要从结算事件中统计,而不是从动画事件中统计。动画可以跳过,但目标进度不能丢。
胜负检查应在连锁完全结束后进行。玩家最后一步触发大连锁,步数已经用完,但连锁可能完成目标;此时不能提前判失败。只有棋盘稳定、无待处理动画、目标统计完成后,才判断胜利或失败。
调试和关卡验证
开发模式可以显示棋盘坐标、随机种子、当前连锁层数、待播放动画数量、可行交换数量。再提供按钮:重洗、显示所有可走步、触发指定特殊棋子、快进动画。三消 bug 往往发生在复杂连锁后,调试工具能节省大量时间。
关卡发布前应跑自动验证:初始棋盘无自动消除,至少有一步可走,障碍数量合理,目标可被生成,步数不明显不足。更进一步,可以用简单 AI 模拟多局,估算通关率。三消关卡平衡仍需要人工,但自动验证能拦住明显配置错误。
上线前检查清单
确认棋盘模型独立于 Sprite;确认匹配检测能处理交叉、障碍和重复;确认掉落补位使用可注入随机源;确认动画队列不决定规则;确认特殊棋子触发顺序明确;确认目标统计来自结算事件;确认胜负在棋盘稳定后判断;确认开发模式能显示坐标、种子和可走步;确认初始棋盘生成无自动消除且有合法移动;确认重洗和道具打断都有清晰状态规则。
三消的爽感来自连锁,但工程质量来自可复现。Phaser 可以把消除和掉落做得很顺滑,但不要让 tween 成为规则。先让棋盘模型和结算流水线可靠,再去调动画节奏和特效,连锁才会既好看又可信。
道具和棋盘状态的交互
三消道具会打破普通交换流程。锤子可以直接消除一个棋子,洗牌可以重排棋盘,刷子可以改变颜色,炸弹可以清除区域。道具也应该进入同一套结算流水线:道具先生成 board operation,Resolver 根据 operation 修改棋盘,再触发匹配、掉落和连锁。不要让道具 Sprite 直接删棋子,否则目标统计、连锁倍率和动画队列都会绕开主流程。
道具使用还要考虑非法目标。锁住的棋子能不能被锤子打?目标物品能不能被换色?障碍下面的棋子能不能被炸?每个道具应声明可作用对象和失败原因。UI 在玩家拖动道具时就给出提示,避免点击后才说不可用。道具消耗要等操作确认成功后发生,失败不能扣道具。
关卡目标与特殊棋子组合
关卡目标越多,结算越要结构化。比如目标是“收集钥匙”,钥匙可能在棋子掉到底部时收集,不是被匹配消除;目标是“打碎冰块”,冰块可能被旁边消除或炸弹影响;目标是“制造 3 个彩虹球”,生成时就计数,而不是使用时计数。这些都需要事件类型:pieceMatched、blockDamaged、pieceCreated、itemCollected。目标系统订阅事件,而不是直接扫描动画。
特殊棋子组合要写测试矩阵。横向炸弹加彩虹球、两个范围炸弹、彩虹球加普通棋子,每种组合都要有明确结果。玩家会记住强力组合,如果某次组合效果不一致,会立刻觉得不公平。配置里可以声明组合优先级,避免两个特殊棋子互相递归触发。
动画节奏和输入缓冲
三消动画不能太慢。交换 150ms、消除 180ms、掉落按距离 80 到 250ms、补位 120ms 是常见区间,但要根据游戏风格调整。连锁多时,可以逐步加速后续掉落,避免玩家等待太久。也可以提供快速模式,缩短动画但不改变结算。动画速度是体验参数,不应影响规则。
输入缓冲可以提高流畅度。棋盘结算接近结束时,允许玩家预选下一步,待棋盘稳定后立即执行。但缓冲必须验证当时棋盘状态,因为连锁可能改变目标棋子。若预选不再合法,清空并提示。不要在棋盘还未稳定时直接交换,否则模型会进入半状态。
失败局和复盘数据
三消关卡失败后,不要只显示“步数用完”。可以记录最后 5 步、可走步数量、剩余目标、是否多次重洗、是否使用道具。设计师分析关卡时,这些数据能说明失败原因:目标生成太少、障碍太多、玩家没有理解特殊棋子,还是步数确实紧。玩家侧可以给轻量提示,比如“还差 2 个冰块,试试范围炸弹”。
复盘也能帮助自动调关。若大量玩家在同一关剩余目标都是同一种障碍,说明障碍产出或清除路径有问题。三消关卡数量通常很多,不能只靠人工逐关观察。客户端埋点要带关卡版本、随机种子和失败摘要。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。