Cocos Creator环形拼图实战:Mask遮罩与旋转交互实现

📅 2026/8/7 15:05:16
Cocos Creator环形拼图实战:Mask遮罩与旋转交互实现
1. 项目概述从一张图到一个可玩的环形拼图最近在社区里看到不少朋友对拼图类游戏的实现感兴趣尤其是那种不规则的、非矩形的拼图。传统的九宫格拼图大家可能都玩腻了今天我想分享一个在Cocos Creator里实现“环形拼图”的完整思路和实战代码。这个项目听起来有点意思它不再是方方正正的格子而是把一张图片像切蛋糕一样分割成多个同心圆环玩家需要旋转这些圆环让图片恢复原状。这不仅仅是换了个皮肤其背后的图片切割逻辑、旋转交互以及拼图成功判定算法都和传统拼图有本质区别。如果你正在寻找一个能综合运用Cocos Creator的Mask遮罩、节点旋转、碰撞检测以及自定义事件的实战案例那么这个环形拼图会是一个绝佳的练手项目。无论是刚入门想挑战一下自己还是有一定经验想拓展思路相信都能从中获得启发。接下来我会从核心思路拆解开始一步步带你实现它。2. 核心思路拆解如何“切割”圆环与判定成功实现一个环形拼图我们首先要跳出“网格”思维。它的核心可以归结为两个根本性问题第一如何将一张矩形的纹理图片视觉上“切割”成多个独立的、可交互的圆环部分第二当玩家旋转这些圆环后如何精确地判断所有圆环是否都已旋转到正确的位置从而判定拼图成功2.1 视觉切割方案双层圆形遮罩的妙用对于第一个问题最直观的想法可能是用程序动态裁剪纹理生成一堆扇环形的小图片。但这会涉及复杂的图像处理性能开销大且不便于管理。在Cocos Creator中有一个更优雅高效的方案使用Mask遮罩组件。我们的思路是“以假乱真”。我们不真正切割图片而是通过遮罩让玩家“看到”图片被切割成了圆环。具体做法是为每一个圆环创建一个独立的节点每个节点下都挂载一张完整的原图作为Sprite。然后为这个节点添加两个Mask组件并且这两个Mask都设置为ELLIPSE椭圆形类型通过巧妙地设置它们的尺寸和Inverted反选属性来“挖”出一个圆环形的显示区域。设想一下我们有一个圆环它有一个内圆半径r_inner和一个外圆半径r_outer。我们可以这样操作第一个Mask外圈Mask尺寸设置为2 * r_outer不反选。它的作用是只显示一个半径为r_outer的圆形区域把图片超出这个圆的部分都隐藏掉。第二个Mask内圈Mask尺寸设置为2 * r_inner并且勾选Inverted反选。这个反选的Mask会挖掉一个半径为r_inner的圆形区域。当这两个Mask同时作用于同一个节点时其视觉效果就是先显示一个大的实心圆外圈Mask的结果然后在这个实心圆中间挖掉一个小的圆形空洞内圈反选Mask的结果。最终屏幕上呈现出来的恰好就是一个圆环通过为不同半径的圆环节点设置不同的内外半径参数我们就能用同一张底图“切割”出多个同心圆环。这是整个项目的图形显示基石。2.2 成功判定逻辑角度比对与容差计算第二个问题成功判定是游戏逻辑的核心。既然每个圆环都是一个可以绕中心旋转的节点那么“正确”的状态就对应于每个圆环节点有一个特定的旋转角度例如0°。玩家通过拖拽旋转圆环改变其rotation属性。我们的判定逻辑就是检查所有可交互圆环当前的旋转角度是否都足够接近它们各自正确的目标角度。这里有几个关键点归一化角度节点的rotation可能会超过360度或为负数。我们需要一个函数将任意角度归一化到[0, 360)的区间内方便比较。角度容差由于操作精度问题我们很难让玩家精确旋转到0.000度。因此需要引入一个容差值例如1.0度。只要当前角度与目标角度的差值绝对值小于这个容差就认为该圆环已就位。整体判定当所有圆环都满足“就位”条件时触发拼图成功事件播放胜利动画或音效。这个判定过程需要在每一帧或玩家每次操作后进行检查通常放在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...)操作要点将完整的拼图原图导入为SpriteFrame资源。为每一个Ring_X节点添加一个Sprite组件并将SpriteFrame设置为这张原图。确保Sprite的Size Mode设置为CUSTOM并手动调整尺寸使其能完全覆盖最大的圆环。在Ring_X节点上添加两个子节点OuterMask和InnerMask空节点即可然后分别为这两个子节点添加Mask组件。关键配置调整两个Mask节点的大小Width和Height。OuterMask的尺寸应等于2 * 本圆环的外半径InnerMask的尺寸应等于2 * 本圆环的内半径。同时务必勾选InnerMask上Mask组件的Inverted属性。确保所有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 交互优化与视觉反馈基础的拖拽旋转功能已经实现但体验可能比较生硬。我们可以从以下几个方面优化惯性旋转在onTouchEnd中根据结束前瞬间的角速度让圆环继续惯性旋转一小段距离并缓动停止。这需要记录最近几帧的deltaAngle来计算速度。磁吸效果当圆环角度接近正确角度在容差范围的2-3倍内时施加一个轻微的“拉力”使其更容易对齐。可以在update中为未被拖拽的、接近正确的圆环添加一个朝向正确角度的微小旋转插值。高亮提示当鼠标悬停或触摸开始时可以改变圆环的透明度或添加一个发光边框Sprite提升操作反馈。音效在开始拖拽、旋转经过特定角度、成功拼合时播放对应的音效。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或触摸事件中直接使用缓存的数据避免每帧调用getComponent或getChildByName。判定优化checkPuzzleComplete函数会在每次触摸移动时调用对于圆环数量较多的情况可以考虑设置一个标志位仅在角度发生变化时进行判定或者降低判定频率如每0.1秒检查一次。5. 常见问题与调试技巧在实际开发中你可能会遇到下面这些问题这里给出我的排查思路和解决方法。5.1 圆环显示异常不显示、显示不全问题现象圆环看不到或者只看到一部分或者中间该镂空的地方没镂空。排查步骤检查节点层级确保OuterMask和InnerMask节点是Ring_X节点的直接子节点并且Sprite组件在Ring_X节点上。检查Mask属性确认OuterMask的Inverted为falseInnerMask的Inverted为true。这是最容易出错的地方。检查Mask尺寸通过UITransform组件仔细核对OuterMask和InnerMask节点的Width和Height。它们应该是直径即2 * radius。确保内圈Mask的尺寸小于外圈Mask。检查Sprite尺寸Ring_X节点上Sprite组件的尺寸必须足够大至少要能完全覆盖最大的外圈Mask。可以临时将Mask禁用看看Sprite图片是否显示正常。查看场景编辑器在Cocos Creator编辑器中选中Mask节点查看场景视图中的遮罩范围显示通常会有线框提示确认其位置和大小是否符合预期。5.2 触摸旋转不灵敏或错乱问题现象很难选中圆环或者旋转时所有圆环一起转或者旋转方向相反。排查步骤检查碰撞区域我们的点击检测是基于数学计算点到中心距离。在onTouchStart中加入调试日志输出计算出的distance、ring.outerRadius和ring.innerRadius看触摸点是否落在预期的环状区域内。确保半径计算正确outerRadius outerMaskNode.width / 2。确认锚点所有Ring_X节点、PuzzleContainer节点的锚点必须都是(0.5, 0.5)否则本地坐标计算会出错。层叠顺序代码中从最外层圆环数组最后一个开始检测确保了视觉上在最上面的圆环优先被交互。如果顺序反了可能会点到被遮挡的圆环。检查ringDataList的填充顺序是否与场景中的节点顺序一致。角度计算检查Math.atan2计算出的角度单位是弧度还是度我们的代码中已转换为度。如果旋转方向相反尝试交换currentTouchAngle和startTouchAngle的相减顺序或在deltaAngle前加一个负号。5.3 成功判定不触发或误触发问题现象明明对齐了却没有成功提示或者稍微一动就提示成功。排查步骤打印角度信息在checkPuzzleComplete函数中打印每个圆环的currentAngle、correctAngle以及它们的差值angleDiff。观察在“对齐”状态下差值是否真的小于你设定的angleTolerance例如2.0。检查归一化函数normalizeAngle函数是关键。测试一些边界值如-10应返回350370应返回10确保其逻辑正确。调整容差值angleTolerance可能设得太小如0.1导致难以触发或太大如10导致容易误触发。根据你的游戏难度预期进行调整。通常2到5度是一个比较合理的范围。判定时机确保checkPuzzleComplete在每次圆环角度发生变化时都被调用。我们的代码在onTouchMove和onTouchEnd中都进行了调用。5.4 在真机上性能不佳问题现象在手机上运行感觉卡顿特别是圆环数量多的时候。优化建议减少Mask数量每个圆环用了两个Mask如果圆环很多比如超过5个Draw Call会显著增加。可以考虑使用自定义Shader来一次性实现环形裁剪但这属于进阶内容。简化判定逻辑如果checkPuzzleComplete在update中每帧调用且圆环数量多可以改为仅在角度实际发生变化时在onTouchMove和onTouchEnd中调用。降低纹理分辨率拼图原图如果尺寸过大会占用大量内存和带宽。确保图片尺寸适配屏幕分辨率即可无需过大。使用Profiler工具在Cocos Creator的构建发布面板中勾选“调试”选项然后在浏览器或真机中使用Chrome DevTools或Cocos Creator自带的Profiler进行性能分析查看帧时间、Draw Call和内存占用找到具体瓶颈。实现这个环形拼图的过程实际上是对Cocos Creator核心模块的一次综合演练。从利用Mask实现非矩形UI到处理复杂的多层触摸交互再到设计一个稳健的状态判定逻辑每一步都需要仔细思考和调试。当你看到自己亲手实现的圆环随着手指流畅旋转并最终“咔哒”一声完美拼合时那种成就感正是游戏开发的乐趣所在。希望这个详细的实现原理和步骤能帮你打开思路创造出更有趣的拼图游戏。