用Cocos Creator开发H5拼图小游戏:从零搭建会动的2D交互Demo

发布时间:2026/10/11 18:57:48
用Cocos Creator开发H5拼图小游戏:从零搭建会动的2D交互Demo
上午刚打开工位电脑茶水间还没走到一半老板就在群里甩了张图过来客户要个拼图小游戏H5的下班前出个demo。我回了句3D拼图我怕是搞不定老板秒回3d拼图我不会用Cocos做个会动的拼图总可以了吧看到这条消息我是真想笑。老板理解的会动不是让我去啃3D模型、光照、物理引擎那一套而是要拼图块在屏幕上有动画、有反馈、能拖拽、拼对了能欢呼。说白了这是个非常典型的2D交互游戏需求而Cocos Creator干这个正好顺手。如果你也遇到过类似的情况——老板/客户说要个会动的XXX而你差点被3D两个字劝退——那这篇文章就是给你写的。带完整工程思路、能用键盘直接抄走的代码、还有我实际踩过的几个坑一步一步说清楚怎么用Cocos Creator在三小时里做一个会动的拼图demo。不涉及任何复杂的3D内容核心就是三件事数据逻辑、触摸交互、tween动画。这篇文章适合刚接触Cocos的开发者也适合被需求逼着快速上手的半路出家人。1. 需求拆解老板嘴里的会动到底是个啥1.1 从一句吐槽变成需求清单程序员最怕的不是需求复杂是需求模糊。但会动的拼图这句话其实比你想的要有用得多。我把老板的原话拆开再结合客户那边可能想要的效果整理成下面这份需求清单拼图块能拖拽移动松手后自动滑到对应的格子位置摆放正确时有明显的成功反馈放大、发光、变亮摆放错误时有失败反馈抖动、变暗拼图开局时所有碎片有一个飞入动画不能直接干巴巴地铺在屏幕上全部拼完后有一个通关动画让客户觉得这钱花得值你看会动就变成了拖拽滑动 自动归位 成功/失败反馈 入场/通关动画。没有一条需要3D。这个思路非常重要几乎所有的我不会XX其实都是我不知道怎么把需求翻译成技术方案。1.2 为什么选Cocos Creator而不是现成库当时我脑子里过的方案有三个第一用原生JS手动实现。可行但拼图涉及的碰撞判定、动画曲线、多点触摸、多分辨率适配全都要自己造轮子半天根本做不完何况我下午还要开两个会。第二找现成的拼图库。GitHub上确实有jigsaw相关的JS库但那种现成库通常长得像拼图游戏模板改UI、改动画、接客户的图都要和别人的代码搏斗遇到bug很难排查。对demo阶段来说反而拖慢速度。第三就是Cocos Creator。它最合适当下的场景场景编辑器和代码逻辑分离UI搭建靠拖拽拼图块做成预制体后一行代码就能批量生成自带tween缓动系统做滑动、缩放、抖动这类动画不需要引入任何额外库导出H5直接就是一个静态站点发给客户用浏览器就能打开连环境都不用配。我最终选了Cocos Creator 3.8因为3.x版本的tween API比2.x更统一触摸事件也走的是标准节点事件写起来顺手。如果你还在用2.4.x也没关系思路一样API换个别名而已。1.3 整体架构数据、表现、动画三层分离开发这种小游戏最忌讳的是把代码全堆在节点脚本里动不动就是this.node.getChildByName这种写法越写越乱。我做任何游戏demo都会把代码分成三层数据层拼图的格子状态、每块拼图当前在第几行第几列、是否归位。这是游戏的真相。逻辑层拖拽开始/结束、交换判定、胜利检测。负责修改数据层并通知表现层。表现层节点位置、Sprite贴图、tween动画调用。只负责好看不做任何判断。为什么这么分因为拼图的需求大概率会变。比如老板明天说把图换成客户logo你只需要换贴图资源后天说改成4x4数据层和生成逻辑都不动。如果你把数据和动画全揉在一个脚本里改一行配置可能牵出三处bug。这个习惯从这个小demo开始养成后面接大项目会轻松非常多。2. 核心玩法设计拼图的数据结构与交互规则2.1 先说清楚拼图块和格子是两个东西新手最容易混的概念拼图块tile是屏幕上能被拖的那些节点格子cell是它们在正确位置上时底下的坑。我见过有人用两个二维数组分别维护最后同步出问题拼图明明拼对了却始终判不了胜利。我的做法是只维护一个二维数组grid它保存的是当前每个格子里放了哪块拼图。拼图块自身的节点属性上再记一个rid原始编号即它在完整图片里的编号等于标准答案。判断胜利时挨个格子看grid[i][j]里那块拼图的rid是否等于i * col j就行。这样只有一个数据源不会出现格子对但块不对的错位问题。2.2 相邻判定与拖拽交换核心交互是玩家按住一块拼图拖到相邻格子的位置松手后把两块拼图的位置交换。如果是拖到不相邻的位置就弹回去。相邻判定其实就一个公式两个格子的曼哈顿距离等于1。// 判断两个格子是否相邻 function isAdjacent(r1: number, c1: number, r2: number, c2: number): boolean { return Math.abs(r1 - r2) Math.abs(c1 - c2) 1; }交换逻辑也很简单但有一个关键点交换的是格子里的拼图块引用而不是拼图块自己的行列属性。做完交换后必须同步更新两个拼图块身上的row和col否则下次拖拽判定时拿到的位置就是旧的会出现明明相邻却说不能交换这种鬼畜问题。2.3 胜利检测与游戏状态机有了数据层胜利检测就是遍历一遍private checkWin(): boolean { for (let r 0; r this.rows; r) { for (let c 0; c this.cols; c) { const tile this.grid[r][c]; if (tile.rid ! r * this.cols c) { return false; } } } return true; }光有这个方法还不够。你得防止玩家在动画播放过程中继续乱点否则会触发动画中的块被再次拖走的bug。所以我给整个游戏维护了一个简单的状态机四个状态状态含义允许的操作Ready初始入场动画播放中不可拖拽Idle等待玩家操作可开始拖拽Swapping两块交换动画播放中不可拖拽Win通关动画播放中不可拖拽状态切换靠一个_state字段加几个判断就搞定了。别觉得小题大做这个demo里状态机可能只省了三五个bug但你写任何交互类游戏都会用到早点习惯这个写法不吃亏。3. 让拼图动起来的动画设计3.1 核心移动动画Cocos的tween到底怎么用Cocos Creator 3.x的tween系统是整个会动的灵魂。它的用法比2.x的cc.tween更直观核心API就四个tween(node)开启动画链.to(duration, props)渐变到目标状态.by()相对变化.call()在某个时间点执行回调最后.start()开始播放。给我的拼图块写一个滑到目标位置的动画import { tween, v3, Vec3 } from cc; // 让拼图块移动到目标格子对应的世界坐标 private tweenMoveToTarget(tile: PuzzleTile, targetPos: Vec3): Promisevoid { return new Promise((resolve) { if (tile.node.position.equals(targetPos)) { resolve(); return; } tween(tile.node) .to(0.25, { position: targetPos }, { easing: sineOut }) .call(() resolve()) .start(); }); }注意我用了sineOut缓动曲线。这是我在反复试手感后确定的线性移动太生硬像纸片被平移sineOut是快速启动、缓慢到位模拟真实物体滑过去然后自然停下的感觉0.25秒这个时长也正好再短会显得急促再长会显得拖沓。缓动曲线是游戏手感的隐形功臣值得在每个动画上都花几秒钟想一想。3.2 反馈动画拼对了与拼错了都得有动静拼图这种玩法反馈就是游戏体验本身。客户不会关心你的代码多优雅他们只关心拖对了有没有感觉。我做的成功反馈是弹一下先放大到1.15倍再回弹到1倍。配合一个半透明的发光底图同步渐显渐隐。private playCorrectFeedback(tile: PuzzleTile): void { // 节点自身的缩放回弹 tween(tile.node) .to(0.08, { scale: v3(1.15, 1.15, 1) }) .to(0.15, { scale: v3(1, 1, 1) }) .start(); // 底图发光效果 const glow tile.glowNode; if (glow) { tween(glow) .to(0.08, { opacity: 255 }) .to(0.2, { opacity: 0 }) .start(); } }错误反馈用的是颤抖动画原理是让节点在X轴上连续做几个小幅度的位移幅度递减。这里有一个细节千万别用.by()无限叠加位移否则做完动画节点位置就偏移了。正确做法是在动画结束后把节点位置强制归回原位或者记录初始位置后做相对恢复到原位。3.3 入场动画开局第一眼决定demo的观感老板说会动其实第一眼看到的东西最重要。如果我直接把拼图铺在屏幕上老板的第一反应一定是这也没动啊。所以我在开局做了拼图碎片的飞入动画每块拼图从屏幕外随机角度飞向自己的格子间隔0.03秒一块整体看起来像是一场碎片雨然后重组成完整图片。这里有个性能细节十几块拼图同时做tween没有任何压力但如果你后续要做几十上百块别用每块一个独立tween的写法改成统一用tween的delay参数错开启动时间或者直接用一个节点统一管理。这个demo里我选择了最简单可靠的方式——在外面用setTimeout错开配合await因为微信小游戏环境里setTimeout的精度其实够用了。4. 实战从零搭建一个会动的拼图Demo4.1 场景搭建与预制体准备打开Cocos Creator 3.8新建一个2D空项目在Canvas底下建好三个节点Board拼图容器挂在屏幕中央所有拼图块都是它的子节点WinMask通关时闪一下的半透明遮罩初始隐藏UI放计时器和步数统计接下来做拼图块的预制体。一个空的节点下面挂三样东西Sprite显示图片切片UITransform设置尺寸Graphics或者一张纯白底图做发光底放在Sprite底下默认隐藏。拼图块大小为120x120所以图片切片必须是正方形的。我用一张确定的图片切好9宫格分别命名part_0.png到part_8.png这些是静态资源运行时直接加载。关于切图方式我用的不是Cocos自带的图片切分而是在制作原图时直接用PS分好格子导出9张图。为什么因为运行时动态切片需要逐像素处理还要处理边缘代码量和排查成本都高而设计阶段就切好图运行时代码只需要根据编号选贴图一行this.getComponent(Sprite).spriteFrame frame就完了。对小demo来说把复杂度往设计阶段挪永远是划算的。4.2 拼图块脚本数据与表现的最小单元每块拼图是一个PuzzleTile组件挂上去。这个组件只负责两件事记住自己的编号和行列提供被拖拽时的起始位置记录。import { _decorator, Component, Node, Vec3, tween, v3, Sprite, SpriteFrame } from cc; const { ccclass, property } _decorator; ccclass(PuzzleTile) export class PuzzleTile extends Component { rid: number 0; // 标准答案编号第几块 row: number 0; // 当前所在行 col: number 0; // 当前所在列 startPos: Vec3 v3(); // 拖拽开始时的节点位置 setContent(frame: SpriteFrame) { this.getComponent(Sprite)!.spriteFrame frame; } }注意startPos这个字段。拖拽的过程中节点的位置一直在变但松手后如果判断出这次拖拽无效你得知道把节点放回哪里。保存拖拽开始瞬间的位置就是干这个的。这是我踩过坑之后补上的——第一次写的时候我忘了保存结果拖歪了只能靠记住之前格子的坐标兜底代码丑得不行。4.3 拼图管理器脚本生成、布局与调度PuzzleGame是挂在Board上的管理器负责开局生成拼图块、处理拖拽事件、执行交换动画和判定胜负。代码分几个部分来讲。先看初始化与布局。生成拼图块时我让每块节点记录自己的格位坐标这个坐标是相对Board的本地坐标。核心公式是第r行第c列的格子中心点位置是((c - (cols-1)/2) * tileSize, ((rows-1)/2 - r) * tileSize)。这样算出来的好处是拼图整体居中不需要再手动偏移。private initPuzzle(): void { this.tiles []; this.grid Array.from({ length: this.rows }, () Array(this.cols).fill(null) ); // 随机打乱编号顺序保证至少有一块不在原位 const shuffled shuffleArray( Array.from({ length: this.rows * this.cols }, (_, i) i) ); for (let r 0; r this.rows; r) { for (let c 0; c this.cols; c) { const rid shuffled[r * this.cols c]; const tileNode instantiate(this.tilePrefab); tileNode.setParent(this.boardNode); const tile tileNode.getComponent(PuzzleTile)!; tile.rid rid; tile.row r; tile.col c; // 设置贴图 const frame this.frames[rid]; tile.setContent(frame); // 计算格子中心坐标 const pos v3( (c - (this.cols - 1) / 2) * this.tileSize, ((this.rows - 1) / 2 - r) * this.tileSize, 0 ); tileNode.position pos; tile.startPos pos; this.grid[r][c] tile; this.tiles.push(tile); } } }这里有个我自己都差点写错的地方rid是标准答案里的编号它决定了贴图用哪一张而row和col是当前所在的位置。两个概念千万不要合并成一个字段否则切图对应关系和判断胜利会互相污染。命名上我特意用rid而不是id来提醒自己这只是一个参考编号。接着看拖拽事件。我选择的是在每块拼图上单独监听触摸事件而不是在Board上统一监听。原因是拼图块判定拖拽是否命中比较简单事件就带出了tile实例不用再做一次坐标反查。对应的隐患是每个节点多一份监听但9个节点的监听开销可以忽略如果将来做100块以上的拼图再改成事件委托。private bindTileEvents(tile: PuzzleTile): void { tile.node.on(NodeEventType.TOUCH_START, (event: EventTouch) { if (this.state ! GameState.Idle) return; this.state GameState.Swapping; this.dragTile tile; // 把节点抬到UI树的顶层避免和其他块碰撞遮罩互相影响 tile.node.setSiblingIndex(this.boardNode.children.length - 1); }, this); tile.node.on(NodeEventType.TOUCH_MOVE, (event: EventTouch) { if (this.dragTile ! tile) return; // 把世界坐标转换成Board的本地坐标 const worldPos event.getUILocation(); const localPos this.boardNode.getComponent(UITransform)! .convertToNodeSpaceAR(v3(worldPos.x, worldPos.y, 0)); // 偏移半块让手指在拼图块中心 tile.node.position new Vec3( localPos.x - this.tileSize / 2, localPos.y this.tileSize / 2, 0 ); }, this); tile.node.on(NodeEventType.TOUCH_END, () this.handleDrop(tile), this); tile.node.on(NodeEventType.TOUCH_CANCEL, () this.handleDrop(tile), this); }松手处理的逻辑是根据手指最终所在位置判断它落在哪个格子里如果落点所在格子与当前格子相邻交换否则原路滑回。private handleDrop(tile: PuzzleTile): void { if (this.dragTile ! tile) return; // 手指当前位置转成格子坐标 const pos tile.node.position; const c Math.round(pos.x / this.tileSize (this.cols - 1) / 2); const r Math.round(-(pos.y / this.tileSize - (this.rows - 1) / 2)); const targetInBound r 0 r this.rows c 0 c this.cols; const targetTile targetInBound ? this.grid[r][c] : null; const selfRow tile.row, selfCol tile.col; if (targetTile isAdjacent(selfRow, selfCol, r, c)) { // 交换数据层 this.grid[selfRow][selfCol] targetTile; this.grid[r][c] tile; targetTile.row selfRow; targetTile.col selfCol; tile.row r; tile.col c; // 让两块同时滑到彼此的格子 const tileTarget this.cellToWorldPos(r, c); const targetTileTarget this.cellToWorldPos(selfRow, selfCol); this.playSwapAnimations(tile, tileTarget, targetTile, targetTileTarget); } else { // 无效操作滑回原位置 tween(tile.node) .to(0.12, { position: this.cellToWorldPos(selfRow, selfCol) }, { easing: backOut }) .call(() { if (this.checkWin()) this.onWin(); }) .start(); } }注意一个特别容易忽略的坑交换动画完成之前必须让游戏处于Swapping状态所有触摸事件直接return。如果没有这个状态锁玩家快速连点两块相邻拼图时会出现上一轮交换动画还没放完下一轮拖拽已经拿到旧数据的错乱。我个人建议在setTimeout或tween的回调里统一调用一个finishSwap()方法把动画结束和状态恢复绑定在一起。4.4 通关动画让完成了三个字变成高光时刻通关时我做了一个三段式动画所有拼图块同时放大1.1倍再回弹整个Board微微晃动一下然后弹出一个恭喜完成的UI面板。这个UI面板是用一个普通节点加Label做的在Canvas里初始隐藏通关时淡入。private onWin(): void { this.state GameState.Win; // 拼图块整体庆祝 tween(this.boardNode) .to(0.06, { scale: v3(1.03, 1.03, 1) }) .to(0.12, { scale: v3(1, 1, 1) }) .start(); // 遮罩先淡入做视觉聚焦 this.winMask.active true; tween(this.winMask) .to(0.2, { opacity: 120 }) .delay(0.3) .to(0.3, { opacity: 255 }) .call(() { this.winPanel.active true; }) .start(); }这里有一个细节遮罩用Sprite组件的话它的透明度可以直接tween但前提是Sprite本身的Color是白色不透明并且节点上不能有UIOpacity组件抢控制权。我第一次就是忘了把Sprite颜色设置好结果tween的opacity没有任何效果排查了半天。Cocos里让节点透明有两套体系Sprite.color和UIOpacity选一套用到底别混用。5. 常见问题排查实录我踩过的四个坑5.1 触摸事件被其他UI挡住第一版做完我直接在浏览器里测试发现拖拽拼图时偶尔会失灵。后来在真机预览发现更严重从拼图块上方快速滑向左侧边缘时触摸直接断掉TOUCH_END没触发拼图块卡在被拖到一半的位置。原因有两个一是Canvas上某些UI节点比如计分板默认参加了事件冒泡但尺寸覆盖到了拼图区域二是当手指移出节点边界时Cocos的触摸事件可能会发TOUCH_CANCEL而不是TOUCH_END如果只在TOUCH_END里做收尾就会漏。解决方案给拼图区域上面不相干的UI节点挂一个BlockInputEvents或者在代码里判断TOUCH_CANCEL也要处理收尾逻辑。我把TOUCH_CANCEL和TOUCH_END都指向了同一个handleDrop这样即使手指划出边界拼图也能正确回到原位不会卡在半空。5.2 tween被打断导致拼图卡死另一个必现bug玩家在交换动画播放中快速点击另一块拼图动画链被新tween打断前面的.call()回调没执行游戏状态一直卡在Swapping所有触摸都被无情return页面看起来就像冻住了一样。排查思路是先看状态机。我在handleDrop入口加了一条日志发现进入时状态永远是Swapping。然后意识到是tween链覆盖问题。Cocos的tween(node)如果对同一个节点连续调用默认会把之前的tween停掉而停止不等于执行回调。修复方案有两个一是动画期间用状态锁彻底禁止所有交互这是治本二是在SWAPPING状态下所有交换动画用tween(...).to().call()串成一条链保证回调一定在动画结束后执行并且给tween链加一个stop()兜底在节点销毁或重置时清掉所有tween。5.3 坐标转换本地坐标和世界坐标的混用每次写Cocos交互坐标转换都是必踩的坑。拼图块监听触摸事件event.getUILocation()拿到的是UI世界坐标Canvas坐标系而拼图块的位置是Board节点下的本地坐标。如果你不做转换直接把世界坐标赋给节点拼图块就会飞到屏幕外的奇奇怪怪位置。正确做法是boardNode.getComponent(UITransform).convertToNodeSpaceAR(worldPos)把它从世界坐标系转成Board的本地坐标系。要注意的是convertToNodeSpaceAR是锚点缩放版本它在转换时会考虑节点的缩放和锚点适合用在UI节点下。如果你用的是普通节点可能要用convertToNodeSpace不带AR的版本这两个方法在节点有缩放时结果不一样。我的Board节点没有缩放所以随便哪个都行但你要是有放大缩小效果记得选对版本。5.4 性能拼图块数量变多怎么办我这个demo里只有9格性能和节点数完全不是问题。但如果老板第二天说要做一个20x20的拼图400个节点同时出现在场景里每个节点还有贴图和阴影某些低端安卓机上就会开始掉帧。两个优化方向提前想好第一把拼图块的对象池化不要在初始化时一次性实例化400个节点而是只实例化可视区域的块滑出屏幕的块回收进对象池重复利用第二把拖拽相关的监听从每个块绑一遍改成在Board上统一监听通过坐标反查命中哪一块。这两条我现在没做是因为没必要但代码的框架已经留好了接口——生成和回收方法单独抽出来将来改起来不用重写。6. 效果调优与交付技巧demo怎么让老板眼前一亮做demo本质上是一次快速提案技术上能跑通只占一半另一半是演示效果。我在实测过程中积累了几个特别出效果的小技巧。先说出场顺序。我强烈建议在游戏启动时加一个延迟让玩家先看到拼图碎片的最终完整图闪现0.5秒再打乱显示碎片飞入。这个先看答案再打乱的细节能让玩家立刻理解玩法目标比直接甩出一堆乱序碎片友好得多。实现只要在initPuzzle里先生成所有块并排列成正确答案0.8秒后再执行打乱和飞入动画。然后是操作手感。拖拽过程中我给被拖拽的拼图块加了一个0.05秒的放大动画视觉效果是这块被拎起来了叠加上层投影。松手后根据结果播放回弹或抖动。这个拾起-放下的手感在小游戏里非常管用玩家会明显感觉到我操作的东西是活的。最后是声音。虽然demo阶段没有配音素材但我用Cocos的AudioSource组件挂了一个很短的咔嗒声在拼图块归位时播放。这个声音用系统自带的一个点击音效改的成本几乎为零但现场演示时音效一响老板直接哎哟不错。声音对游戏完成度的提升往往是超预期的千万别忽略。还有交付环节的一个建议导出H5后把构建产物放到一个静态目录手机扫码就能打开。第一次演示请在真机上进行不要在浏览器里演示因为触摸手感在鼠标和手指下差异很大万一老板上手一滑觉得不跟手前面的好感全没了。我通常会准备两个构建一个竖版手机页面一个横版桌面页面因为客户很可能在不同设备上打开而Cocos的多分辨率适配只需要在Canvas的Fit Width和Fit Height上做简单配置就能兼顾两种模式。7. 复盘这次被逼出来的拼图教会我什么整个项目从接到需求到交付demo实际花了三个多小时其中调试触摸坐标和tween状态锁占了一半时间。如果让我重新做一次我会把时间分配改成花40分钟想清楚会动到底分层在哪确认交互逻辑和动画需求花一个小时搭建场景和预制体剩下时间全部留给手感调试。这次最大的收获是二八法则一个让老板满意的会动的拼图80%的卖点在于——飞入动画够炫、交换滑动够顺、成功反馈够响。这三件事都只需要基础tween就能实现。真正复杂的拼图算法比如拼图块轮廓匹配、旋转检测在这个需求里根本用不到我也确实不会但不需要用。老板说用Cocos做个会动的拼图他想要的从来不是一个完整的3D拼图模拟器而是一个能拖、能滑、能看到效果的游戏。另外还想说一句被需求逼着学东西其实是效率最高的学习方式。我这次虽然没碰3D但为了处理拖拽坐标我把Cocos的UITransform和坐标转换体系顺带摸清楚了为了处理动画打断我把tween底层的链与回调机制也理解透了。这些都是下次做更复杂项目时一定用得上的地基。最后分享一个我实际用起来很顺手的建议如果你也接到了一个看起来越界的需求先别急着说不会把需求里的动词拆出来——会动是哪些动作好看是哪些反馈大部分时候老板提的是效果词你真正要做的是把效果翻译成具体的交互和动画这才是游戏开发日常最有意思的部分。