Cocos Creator环形拼图实战:Mask遮罩与旋转交互实现
2026/8/7 15:05:09 网站建设 项目流程

1. 项目概述:从一张图到一个可玩的环形拼图

最近在社区里看到不少朋友对拼图类游戏的实现感兴趣,尤其是那种不规则的、非矩形的拼图。传统的九宫格拼图大家可能都玩腻了,今天我想分享一个在Cocos Creator里实现“环形拼图”的完整思路和实战代码。这个项目听起来有点意思,它不再是方方正正的格子,而是把一张图片像切蛋糕一样,分割成多个同心圆环,玩家需要旋转这些圆环,让图片恢复原状。这不仅仅是换了个皮肤,其背后的图片切割逻辑旋转交互以及拼图成功判定算法,都和传统拼图有本质区别。如果你正在寻找一个能综合运用Cocos Creator的Mask遮罩节点旋转碰撞检测以及自定义事件的实战案例,那么这个环形拼图会是一个绝佳的练手项目。无论是刚入门想挑战一下自己,还是有一定经验想拓展思路,相信都能从中获得启发。接下来,我会从核心思路拆解开始,一步步带你实现它。

2. 核心思路拆解:如何“切割”圆环与判定成功

实现一个环形拼图,我们首先要跳出“网格”思维。它的核心可以归结为两个根本性问题:第一,如何将一张矩形的纹理图片,视觉上“切割”成多个独立的、可交互的圆环部分?第二,当玩家旋转这些圆环后,如何精确地判断所有圆环是否都已旋转到正确的位置,从而判定拼图成功?

2.1 视觉切割方案:双层圆形遮罩的妙用

对于第一个问题,最直观的想法可能是用程序动态裁剪纹理,生成一堆扇环形的小图片。但这会涉及复杂的图像处理,性能开销大,且不便于管理。在Cocos Creator中,有一个更优雅高效的方案:使用Mask(遮罩)组件

我们的思路是“以假乱真”。我们不真正切割图片,而是通过遮罩,让玩家“看到”图片被切割成了圆环。具体做法是为每一个圆环创建一个独立的节点,每个节点下都挂载一张完整的原图作为Sprite。然后,为这个节点添加两个Mask组件,并且这两个Mask都设置为ELLIPSE(椭圆形)类型,通过巧妙地设置它们的尺寸和Inverted(反选)属性,来“挖”出一个圆环形的显示区域。

设想一下,我们有一个圆环,它有一个内圆半径r_inner和一个外圆半径r_outer。我们可以这样操作:

  1. 第一个Mask(外圈Mask):尺寸设置为2 * r_outer,不反选。它的作用是只显示一个半径为r_outer的圆形区域,把图片超出这个圆的部分都隐藏掉。
  2. 第二个Mask(内圈Mask):尺寸设置为2 * r_inner,并且勾选Inverted(反选)。这个反选的Mask会挖掉一个半径为r_inner的圆形区域。

当这两个Mask同时作用于同一个节点时,其视觉效果就是:先显示一个大的实心圆(外圈Mask的结果),然后在这个实心圆中间挖掉一个小的圆形空洞(内圈反选Mask的结果)。最终,屏幕上呈现出来的,恰好就是一个圆环!通过为不同半径的圆环节点设置不同的内外半径参数,我们就能用同一张底图,“切割”出多个同心圆环。这是整个项目的图形显示基石。

2.2 成功判定逻辑:角度比对与容差计算

第二个问题,成功判定,是游戏逻辑的核心。既然每个圆环都是一个可以绕中心旋转的节点,那么“正确”的状态,就对应于每个圆环节点有一个特定的旋转角度(例如)。玩家通过拖拽旋转圆环,改变其rotation属性。我们的判定逻辑就是检查所有可交互圆环当前的旋转角度,是否都足够接近它们各自正确的目标角度。

这里有几个关键点:

  1. 归一化角度:节点的rotation可能会超过360度或为负数。我们需要一个函数将任意角度归一化到[0, 360)的区间内,方便比较。
  2. 角度容差:由于操作精度问题,我们很难让玩家精确旋转到0.000度。因此需要引入一个容差值(例如1.0度)。只要当前角度与目标角度的差值绝对值小于这个容差,就认为该圆环已就位。
  3. 整体判定:当所有圆环都满足“就位”条件时,触发拼图成功事件,播放胜利动画或音效。

这个判定过程需要在每一帧或玩家每次操作后进行检查,通常放在update函数或一个自定义的检查函数中。

3. 详细实现步骤与代码解析

理解了核心思路,我们开始动手实现。我会按照创建项目、搭建场景、编写脚本的顺序,把关键代码和配置都列出来。

3.1 场景与节点结构搭建

首先,在Cocos Creator中创建一个新场景,并构建如下节点树:

Canvas (画布) ├── Background (背景层,可选) └── PuzzleContainer (拼图容器,用于居中管理所有圆环) ├── Ring_1 (圆环1节点) │ ├── OuterMask (Mask组件,Type: ELLIPSE, Inverted: false) │ └── InnerMask (Mask组件,Type: ELLIPSE, Inverted: true) ├── Ring_2 (圆环2节点) │ ├── OuterMask │ └── InnerMask └── ... (可以继续添加Ring_3, Ring_4...)

操作要点

  1. 将完整的拼图原图导入为SpriteFrame资源。
  2. 为每一个Ring_X节点添加一个Sprite组件,并将SpriteFrame设置为这张原图。确保SpriteSize Mode设置为CUSTOM,并手动调整尺寸,使其能完全覆盖最大的圆环。
  3. Ring_X节点上添加两个子节点OuterMaskInnerMask(空节点即可),然后分别为这两个子节点添加Mask组件。
  4. 关键配置:调整两个Mask节点的大小(WidthHeight)。OuterMask的尺寸应等于2 * 本圆环的外半径InnerMask的尺寸应等于2 * 本圆环的内半径。同时,务必勾选InnerMask上Mask组件的Inverted属性。
  5. 确保所有Ring_X节点以及PuzzleContainer节点的锚点(Anchor)都在中心(0.5, 0.5),这样旋转才会围绕中心进行。

3.2 核心脚本:RingPuzzleManager.ts

我们创建一个名为RingPuzzleManager.ts的脚本,并将其挂载到PuzzleContainer节点上,作为游戏的总控制器。

import { _decorator, Component, Node, Mask, EventTouch, v2, v3, Quat, UITransform } from 'cc'; const { ccclass, property } = _decorator; // 定义单个圆环的数据结构 interface RingData { node: Node; // 圆环节点 outerMask: Mask; // 外遮罩组件 innerMask: Mask; // 内遮罩组件 outerRadius: number; // 外半径 innerRadius: number; // 内半径 correctAngle: number; // 正确角度(通常为0) currentAngle: number; // 当前角度 isDragging: boolean; // 是否正在被拖拽 } @ccclass('RingPuzzleManager') export class RingPuzzleManager extends Component { @property([Node]) ringNodes: Node[] = []; // 在编辑器中拖入所有Ring节点 @property angleTolerance: number = 2.0; // 角度容差,单位度 private ringDataList: RingData[] = []; private currentDragRing: RingData | null = null; private startTouchAngle: number = 0; private startRingAngle: number = 0; onLoad() { this.initRings(); } // 初始化所有圆环数据 initRings() { this.ringDataList = []; for (let ringNode of this.ringNodes) { const outerMaskNode = ringNode.getChildByName('OuterMask'); const innerMaskNode = ringNode.getChildByName('InnerMask'); if (!outerMaskNode || !innerMaskNode) { console.error(`Ring node ${ringNode.name} 缺少Mask子节点!`); continue; } const outerMask = outerMaskNode.getComponent(Mask); const innerMask = innerMaskNode.getComponent(Mask); // 根据Mask尺寸推算半径(假设Mask是正方形,宽高一致) const outerRadius = outerMaskNode.getComponent(UITransform)!.width / 2; const innerRadius = innerMaskNode.getComponent(UITransform)!.width / 2; const ringData: RingData = { node: ringNode, outerMask: outerMask, innerMask: innerMask, outerRadius: outerRadius, innerRadius: innerRadius, correctAngle: 0, // 默认正确角度为0 currentAngle: this.normalizeAngle(ringNode.angle), // 获取并归一化当前角度 isDragging: false }; this.ringDataList.push(ringData); // 为圆环节点添加触摸监听 ringNode.on(Node.EventType.TOUCH_START, this.onTouchStart, this); ringNode.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); ringNode.on(Node.EventType.TOUCH_END, this.onTouchEnd, this); ringNode.on(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this); } // 初始打乱顺序 this.shuffleRings(); } // 将角度归一化到 [0, 360) 区间 normalizeAngle(angle: number): number { angle = angle % 360; if (angle < 0) angle += 360; return angle; } // 打乱所有圆环的起始角度 shuffleRings() { for (let ring of this.ringDataList) { // 随机一个角度,但避免过于接近正确角度(比如留出至少20度差距) let randomAngle; do { randomAngle = Math.floor(Math.random() * 360); } while (Math.abs(this.normalizeAngle(randomAngle - ring.correctAngle)) < 20); ring.node.angle = randomAngle; ring.currentAngle = this.normalizeAngle(randomAngle); } this.checkPuzzleComplete(); // 打乱后检查一次(理论上不会成功) } // 触摸开始事件 onTouchStart(event: EventTouch) { if (this.currentDragRing) return; // 防止同时拖拽多个 const touchPos = event.getUILocation(); const worldPos = v3(touchPos.x, touchPos.y, 0); const localPos = this.node.getComponent(UITransform)!.convertToNodeSpaceAR(worldPos); // 找出被点击的圆环(从最外层向内检测,实现层叠点击) for (let i = this.ringDataList.length - 1; i >= 0; i--) { const ring = this.ringDataList[i]; const distance = v2(localPos.x, localPos.y).length(); // 判断触摸点是否在该圆环的范围内 if (distance <= ring.outerRadius && distance >= ring.innerRadius) { this.currentDragRing = ring; ring.isDragging = true; // 计算起始触摸向量与角度 this.startTouchAngle = Math.atan2(localPos.y, localPos.x) * 180 / Math.PI; this.startRingAngle = ring.currentAngle; break; } } } // 触摸移动事件:旋转圆环 onTouchMove(event: EventTouch) { if (!this.currentDragRing) return; const touchPos = event.getUILocation(); const worldPos = v3(touchPos.x, touchPos.y, 0); const localPos = this.node.getComponent(UITransform)!.convertToNodeSpaceAR(worldPos); // 计算当前触摸向量与角度 const currentTouchAngle = Math.atan2(localPos.y, localPos.x) * 180 / Math.PI; // 计算角度变化量 let deltaAngle = currentTouchAngle - this.startTouchAngle; // 处理角度跨越360度边界的情况,使旋转更平滑 if (deltaAngle > 180) deltaAngle -= 360; if (deltaAngle < -180) deltaAngle += 360; // 应用旋转 const newAngle = this.startRingAngle + deltaAngle; this.currentDragRing.node.angle = newAngle; this.currentDragRing.currentAngle = this.normalizeAngle(newAngle); // 实时检查拼图是否完成(也可以放在onTouchEnd中) this.checkPuzzleComplete(); } // 触摸结束/取消事件 onTouchEnd() { if (this.currentDragRing) { this.currentDragRing.isDragging = false; this.currentDragRing = null; } // 最终检查一次 this.checkPuzzleComplete(); } // 检查拼图是否完成 checkPuzzleComplete() { let isComplete = true; for (const ring of this.ringDataList) { const angleDiff = Math.abs(this.normalizeAngle(ring.currentAngle - ring.correctAngle)); // 如果有一个圆环的角度差大于容差,则未完成 if (angleDiff > this.angleTolerance) { isComplete = false; break; } } if (isComplete) { console.log('拼图成功!'); // 触发成功事件:可以播放动画、音效、弹出胜利UI等 this.onPuzzleSolved(); } } // 拼图成功后的处理 onPuzzleSolved() { // 例如:停止所有交互 this.ringNodes.forEach(node => { node.off(Node.EventType.TOUCH_START, this.onTouchStart, this); node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this); node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this); }); // 可以添加粒子特效、胜利音效、UI提示等 // this.scheduleOnce(() => { /* 显示胜利面板 */ }, 0.5); } update(deltaTime: number) { // 如果需要更复杂的动画或逻辑,可以在这里更新 } }

3.3 交互优化与视觉反馈

基础的拖拽旋转功能已经实现,但体验可能比较生硬。我们可以从以下几个方面优化:

  1. 惯性旋转:在onTouchEnd中,根据结束前瞬间的角速度,让圆环继续惯性旋转一小段距离并缓动停止。这需要记录最近几帧的deltaAngle来计算速度。
  2. 磁吸效果:当圆环角度接近正确角度(在容差范围的2-3倍内)时,施加一个轻微的“拉力”,使其更容易对齐。可以在update中为未被拖拽的、接近正确的圆环添加一个朝向正确角度的微小旋转插值。
  3. 高亮提示:当鼠标悬停或触摸开始时,可以改变圆环的透明度或添加一个发光边框Sprite,提升操作反馈。
  4. 音效:在开始拖拽、旋转经过特定角度、成功拼合时,播放对应的音效。

4. 进阶扩展与性能优化思路

一个基础版本完成后,我们可以考虑让它变得更专业、更丰富。

4.1 游戏性扩展设计

  • 多关卡系统:创建不同的图片资源,每张图片对应一个关卡。通过数据配置表(如JSON)来管理每个关卡的图片路径、圆环数量、内外半径等参数。RingPuzzleManager在初始化时读取配置,动态创建圆环节点和Mask。
  • 难度梯度
    • 圆环数量:从2个环逐步增加到5-6个环。
    • 环宽:减少圆环的宽度(即outerRadius - innerRadius),增加对齐精度要求。
    • 干扰元素:在圆环上随机添加一些装饰性的、方向性的标记,增加视觉干扰,或者让正确角度不再是0度,而是某个特定值。
    • 旋转限制:某些圆环只能旋转固定角度(如90度的倍数),增加策略性。
  • 计时与步数:记录玩家完成拼图所用的时间和旋转步数,作为关卡评分的依据。

4.2 性能与代码优化要点

  • 动态合批:确保所有Ring_X节点下的Sprite使用相同的纹理和材质,这样Cocos Creator的渲染引擎才有可能对它们进行动态合批,减少Draw Call,提升渲染性能。
  • 事件监听管理:在游戏结束或场景切换时,务必使用node.off移除所有注册的事件监听器,防止内存泄漏。
  • 避免频繁查找节点:在initRings中,一次性获取所有需要的组件(Mask,UITransform)并存入RingData结构。在update或触摸事件中,直接使用缓存的数据,避免每帧调用getComponentgetChildByName
  • 判定优化checkPuzzleComplete函数会在每次触摸移动时调用,对于圆环数量较多的情况,可以考虑设置一个标志位,仅在角度发生变化时进行判定,或者降低判定频率(如每0.1秒检查一次)。

5. 常见问题与调试技巧

在实际开发中,你可能会遇到下面这些问题,这里给出我的排查思路和解决方法。

5.1 圆环显示异常(不显示、显示不全)

  • 问题现象:圆环看不到,或者只看到一部分,或者中间该镂空的地方没镂空。
  • 排查步骤
    1. 检查节点层级:确保OuterMaskInnerMask节点是Ring_X节点的直接子节点,并且Sprite组件在Ring_X节点上。
    2. 检查Mask属性:确认OuterMaskInvertedfalseInnerMaskInvertedtrue。这是最容易出错的地方。
    3. 检查Mask尺寸:通过UITransform组件,仔细核对OuterMaskInnerMask节点的WidthHeight。它们应该是直径,即2 * radius。确保内圈Mask的尺寸小于外圈Mask。
    4. 检查Sprite尺寸Ring_X节点上Sprite组件的尺寸必须足够大,至少要能完全覆盖最大的外圈Mask。可以临时将Mask禁用,看看Sprite图片是否显示正常。
    5. 查看场景编辑器:在Cocos Creator编辑器中,选中Mask节点,查看场景视图中的遮罩范围显示(通常会有线框提示),确认其位置和大小是否符合预期。

5.2 触摸旋转不灵敏或错乱

  • 问题现象:很难选中圆环,或者旋转时所有圆环一起转,或者旋转方向相反。
  • 排查步骤
    1. 检查碰撞区域:我们的点击检测是基于数学计算(点到中心距离)。在onTouchStart中加入调试日志,输出计算出的distancering.outerRadiusring.innerRadius,看触摸点是否落在预期的环状区域内。确保半径计算正确(outerRadius = outerMaskNode.width / 2)。
    2. 确认锚点:所有Ring_X节点、PuzzleContainer节点的锚点必须都是(0.5, 0.5),否则本地坐标计算会出错。
    3. 层叠顺序:代码中从最外层圆环(数组最后一个)开始检测,确保了视觉上在最上面的圆环优先被交互。如果顺序反了,可能会点到被遮挡的圆环。检查ringDataList的填充顺序是否与场景中的节点顺序一致。
    4. 角度计算:检查Math.atan2计算出的角度单位是弧度还是度,我们的代码中已转换为度。如果旋转方向相反,尝试交换currentTouchAnglestartTouchAngle的相减顺序,或在deltaAngle前加一个负号。

5.3 成功判定不触发或误触发

  • 问题现象:明明对齐了却没有成功提示,或者稍微一动就提示成功。
  • 排查步骤
    1. 打印角度信息:在checkPuzzleComplete函数中,打印每个圆环的currentAnglecorrectAngle以及它们的差值angleDiff。观察在“对齐”状态下,差值是否真的小于你设定的angleTolerance(例如2.0)。
    2. 检查归一化函数normalizeAngle函数是关键。测试一些边界值,如-10(应返回350),370(应返回10),确保其逻辑正确。
    3. 调整容差值angleTolerance可能设得太小(如0.1)导致难以触发,或太大(如10)导致容易误触发。根据你的游戏难度预期进行调整。通常2到5度是一个比较合理的范围。
    4. 判定时机:确保checkPuzzleComplete在每次圆环角度发生变化时都被调用。我们的代码在onTouchMoveonTouchEnd中都进行了调用。

5.4 在真机上性能不佳

  • 问题现象:在手机上运行感觉卡顿,特别是圆环数量多的时候。
  • 优化建议
    1. 减少Mask数量:每个圆环用了两个Mask,如果圆环很多(比如超过5个),Draw Call会显著增加。可以考虑使用自定义Shader来一次性实现环形裁剪,但这属于进阶内容。
    2. 简化判定逻辑:如果checkPuzzleCompleteupdate中每帧调用,且圆环数量多,可以改为仅在角度实际发生变化时(在onTouchMoveonTouchEnd中)调用。
    3. 降低纹理分辨率:拼图原图如果尺寸过大,会占用大量内存和带宽。确保图片尺寸适配屏幕分辨率即可,无需过大。
    4. 使用Profiler工具:在Cocos Creator的构建发布面板中,勾选“调试”选项,然后在浏览器或真机中使用Chrome DevTools或Cocos Creator自带的Profiler进行性能分析,查看帧时间、Draw Call和内存占用,找到具体瓶颈。

实现这个环形拼图的过程,实际上是对Cocos Creator核心模块的一次综合演练。从利用Mask实现非矩形UI,到处理复杂的多层触摸交互,再到设计一个稳健的状态判定逻辑,每一步都需要仔细思考和调试。当你看到自己亲手实现的圆环随着手指流畅旋转,并最终“咔哒”一声完美拼合时,那种成就感正是游戏开发的乐趣所在。希望这个详细的实现原理和步骤能帮你打开思路,创造出更有趣的拼图游戏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询