1. 项目概述:从爆款现象到技术实现路径
最近一款名为“水排序谜题”的休闲游戏在各大平台悄然走红,其核心玩法简单到极致:将不同颜色的液体在试管间倾倒、排序,最终让所有试管内的颜色归位。这种看似“解压”的小游戏,背后却隐藏着对动画流畅度、物理表现和交互反馈的极高要求。尤其是液体流动时那种丝滑的、带有粘滞感和表面张力的视觉效果,绝非简单的帧动画或序列帧能够胜任。这正是我们今天要深入探讨的核心:如何利用 CocosCreator 引擎,结合专业的 Spine 2D骨骼动画系统,从零开始构建一套能够支撑此类“流体模拟”体验的动画与交互架构。
作为一名在游戏前端开发领域摸爬滚打了十多年的老鸟,我见过太多团队在面临这类需求时的挣扎。要么是美术输出海量的序列帧,导致包体臃肿、内存吃紧;要么是程序员试图用代码硬模拟流体,结果效果生硬、性能堪忧。而 Spine 的出现,恰恰为 2D 游戏,尤其是需要细腻动作和变形动画的游戏,提供了一个绝佳的解决方案。它允许我们将动画数据(骨骼、插槽、网格、动画)与渲染资源(图集)分离,通过运行时计算骨骼变换来驱动顶点动画,从而实现极其流畅和高效的动画播放。在 CocosCreator 中集成 Spine,更是如虎添翼,让我们能够专注于游戏逻辑与 Spine 动画控制的深度结合。
本篇文章,我将彻底拆解“水排序”这类游戏的核心动画需求,并分享一套基于 CocosCreator 3.x 版本与 Spine 的实战设计方案。内容将涵盖从 Spine 动画资源的优化导出、在 CocosCreator 中的高效集成与控制,到复杂交互逻辑(如触摸事件传递管理、动画状态机设计)的实现细节。无论你是刚刚接触 Spine 的新手,还是希望优化现有工作流的老手,都能从中找到可以直接“抄作业”的实用技巧和避坑指南。
2. 核心需求解析与技术选型考量
2.1 “水排序”游戏的动画与交互需求拆解
要复现“水排序”的游戏体验,我们首先需要将其视觉和交互元素分解为可技术实现的需求点:
液体实体及其状态:每种颜色的液体是一个独立的、可交互的实体。它需要具备多种状态:静止在试管中、被选中高亮、正在倾倒流动、与其他液体混合(颜色交界处的渐变模拟)。液体的形状会随着试管内剩余容量和倾倒动作实时变化,这要求动画系统必须支持网格变形。
倾倒动作的连贯性与物理感:这是体验的核心。倾倒不是简单的位移,它包含几个阶段:从试管中“提起”形成一股水流、水流在空中划出抛物线轨迹、落入目标试管后与现有液体碰撞、溅起微小水花并最终融合。整个过程需要平滑衔接,并且要有重量感和粘滞感,避免像倒白开水一样轻飘飘。
试管与界面的交互反馈:试管作为容器,需要响应点击、触摸事件。当点击一个装有液体的试管时,需要高亮该试管及其顶部的液体,并可能有一个轻微的“被拿起”的位移提示。这里涉及到 CocosCreator 中Touch 事件的处理与传递控制,确保点击事件能准确被液体或试管接收,且不会产生误触。
性能与资源管理:游戏可能包含数十种颜色、多个关卡,如果每种颜色的每种状态都用独立的精灵或序列帧,内存和包体大小将不可控。我们需要一套共享资源、通过参数控制变化的机制。
2.2 为什么是 CocosCreator + Spine?
面对上述需求,我们评估几种常见方案:
- 纯序列帧动画:对于液体复杂的形变和流动,需要极多的关键帧才能流畅,资源体积巨大,且无法实现运行时基于逻辑的形变(如根据试管内液面高度实时调整液体形状),首先排除。
- 粒子系统:可以模拟飞溅、水滴等效果,非常适合作为辅助(如倾倒时的溅射粒子),但难以表现一个具有固定形状、可交互、且需要精确控制其轮廓的液体主体。
- 程序化网格变形(Shader/顶点动画):理论上可行,可以实现非常动态的效果,但开发成本极高,需要强大的图形学知识和数学功底,且美术难以参与制作和调整,不利于团队协作和内容迭代。
- Spine 骨骼动画:这是我们的最终选择。原因如下:
- 网格变形与蒙皮:Spine 的核心功能就是骨骼驱动网格顶点。我们可以为“一股水流”创建一个网格,然后通过控制几根核心骨骼的旋转、位移,来让网格产生流畅的弯曲、拉伸形变,完美模拟液体流动的轨迹。
- 动画复用与混合:Spine 支持动画混合。例如,“静止”动画和“流动”动画可以平滑过渡;“提起”动作和“倾倒”动作可以按时间线组合。这大大减少了动画制作量。
- 美术友好,迭代快:动画师可以在 Spine 编辑器中直观地创作和调整液体流动的每一帧,所见即所得。程序只需关心在何时播放哪个动画,以及如何用代码干预某些骨骼的位置(例如,让水流末端始终对准目标试管口)。
- 性能优异:Spine 运行时只存储骨骼、网格和动画关键帧数据,渲染时通过 GPU 计算顶点位置。一个复杂的液体动画,其数据量远小于同等效果的序列帧。通过共享图集,不同颜色的液体可以共用同一套骨骼和动画数据,仅通过切换附着在骨骼上的颜色贴图来实现换色,极大地节省了内存。
CocosCreator作为游戏引擎,提供了完整的生命周期管理、组件系统、物理引擎(如果需要更复杂的碰撞)、以及成熟的Spine 运行时支持(sp.Skeleton组件)。其基于 TypeScript 的开发体验和强大的编辑器,使得整合 Spine 资源、编写控制逻辑、调试动画状态变得非常高效。
3. Spine 动画资源的设计与优化导出
3.1 液体动画的 Spine 层级与骨骼设计
在 Spine 编辑器中设计液体动画,结构清晰是关键。以下是一个推荐的层级结构:
- 根骨骼 (root) |- 液体主体骨骼 (liquid_body) | |- 颜色插槽 (color_slot) // 附着液体颜色的网格或图片 | |- 高光/反射插槽 (highlight_slot) // 可选,用于增强质感 |- 水流骨骼链 (pour_chain) // 用于倾倒动作 | |- 水流骨骼_01 (pour_bone_01) | |- 水流骨骼_02 (pour_bone_02) | |- 水流网格插槽 (pour_mesh_slot) // 附着水流网格 |- 溅射粒子挂点骨骼 (splash_bone) // 用于附着粒子效果设计要点:
- 液体主体:通常是一个由简单网格构成的形状。
liquid_body骨骼控制其整体位置和缩放。color_slot上的附件可以是一个纯色网格,也可以是一张带有渐变和通透感的纹理。这里有一个关键技巧:为了在 CocosCreator 中实现动态换色,我们通常使用一张白色的、带有 Alpha 通道的纹理作为基础,然后在代码中通过修改sp.Skeleton组件的颜色属性(color)或插槽颜色(slot.color)来改变显示颜色。这样,所有颜色的液体共享同一套纹理图集。 - 水流骨骼链:这是模拟倾倒的核心。使用至少3-4根骨骼组成一条链(类似 IK 链)。通过为这些骨骼制作位移动画,来驱动附着在末端的
pour_mesh_slot上的网格产生形变。网格本身可以设计成一头粗一头细,以模拟水流。动画师需要仔细调整骨骼运动曲线,使其具有“先加速后减速”的物理感。 - 层级拆分:将“主体”和“水流”放在不同的骨骼层级下,有利于动画控制。例如,我们可以单独播放水流的倾倒动画,而保持液体主体静止(表示液体正在减少)。Spine 的动画混合功能可以让我们将“主体缩放动画”和“水流倾倒动画”叠加播放。
3.2 关键动画片段制作
在 Spine 中,我们需要制作以下几个核心动画片段(Animation):
- idle:液体在试管中静止的状态。可能包含非常缓慢的、细微的波动,以增加生动感。
- selected:被选中时的高亮效果,可以是颜色变亮、增加外发光轮廓动画等。
- pour_start:倾倒开始,液体从试管口“探出头”,水流骨骼链开始延伸。
- pour_loop:倾倒循环,水流持续流动。这个动画应该是循环的。
- pour_end:倾倒结束,水流收回,最后一滴液体滴落。
- fill:液体被注入试管时,液面上升的动画。这可以通过缩放
liquid_body骨骼的 Y 轴来实现。
动画混合设置:在 Spine 编辑器或 CocosCreator 代码中,我们可以设置pour_start->pour_loop->pour_end的连续播放,并在pour_loop阶段根据游戏逻辑(是否还有液体可倒)决定何时触发pour_end。
3.3 导出优化:如何让文件更小、内存更少
这是直接关系到游戏性能与用户体验的部分。很多团队忽视了导出设置,导致上线后卡顿、发热。
图集(Atlas)优化:
- 纹理尺寸:确保纹理尺寸是2的幂(如 512x512, 1024x1024)。CocosCreator 和 GPU 对此有优化。
- 纹理格式:根据效果需求选择。如果颜色简单,使用
RGBA8888;如果想节省内存,且能接受轻微质量损失,可以考虑RGBA4444或RGB565(若无透明通道)。在 CocosCreator 的项目设置中可以配置默认纹理压缩格式。 - 剔除空白:Spine 导出时务必勾选“Trim”(裁剪)选项,移除图片周围的透明像素,能有效减少纹理面积,提升图集打包效率。
- 共享图集:将所有液体的基础纹理(白色底纹)、UI元素等尽可能打包到一张或少数几张图集中,减少 Draw Call。
骨骼动画数据优化:
- 减少不必要的关键帧:Spine 动画是基于时间线的关键帧插值。动画师在制作时,应避免在每一帧都设置关键帧。对于匀速或规律运动,只需在变化起点和终点设置关键帧,Spine 会自动插值。导出前,可以使用 Spine 编辑器的“精简关键帧”功能。
- 控制顶点数量:“Spine顶点变换数多少比较合适?”这是一个常见问题。对于液体网格,顶点数并非越多越好。顶点数越多,形变越平滑,但 GPU 需要计算的顶点变换也越多。一般来说,模拟一股简单的水流,网格由 15-30 个顶点构成已经能获得不错的效果。对于静止的液体主体,一个由 8-12 个顶点组成的简单多边形网格足以。原则是:在满足视觉效果的前提下,尽可能减少网格顶点数。你可以在 Spine 中通过“网格工具”来调整网格密度。
- 禁用不需要的动画数据:如果某个插槽在某个动画中完全不动,确保它没有多余的关键帧。检查并删除动画中未使用的插槽或骨骼。
导出设置:
- 版本:确保导出的 Spine 数据版本与 CocosCreator 中使用的 Spine 运行时版本兼容。通常使用最新的稳定版即可。
- 非必要数据不导出:在导出 JSON 或二进制格式时,有些数据(如画布大小、非关键时间线)可以选择性不导出。二进制格式(.skel)通常比 JSON 格式(.json)文件更小,解析更快,是生产环境的推荐选择。
注意:优化是一个权衡的过程。需要在效果、内存、CPU/GPU 开销之间找到平衡点。建议在真机上进行性能剖析(Profiling),重点关注
draw call数量、render time以及Spine组件的更新耗时。
4. 在 CocosCreator 中的集成与核心控制逻辑
4.1 资源导入与组件配置
将 Spine 导出的.skel(或.json)文件和对应的.atlas文件以及纹理图片(.png)放入 CocosCreator 项目的assets目录下。引擎会自动识别并生成对应的SkeletonData资源。
在场景中创建一个节点,为其添加sp.Skeleton组件。然后将生成的SkeletonData资源拖拽到组件的Skeleton Data属性上。此时,你应该能在场景编辑器中看到默认的动画。
组件参数调优:
- Animation Cache Mode:对于频繁播放、动画数量不多的角色(如液体),使用
SHARED_CACHE模式可以提高性能,多个同类型骨骼动画实例共享动画数据缓存。对于独一无二的角色,使用PRIVATE_CACHE。 - Default Skin:设置默认皮肤。如果我们使用“换色”方案,这里可以设置一个基础皮肤(如
default)。 - Time Scale:全局播放速度,一般保持为1。
4.2 动画播放与状态管理
在代码中(例如LiquidController.ts),我们需要获得sp.Skeleton组件的引用,并对其进行控制。
import { _decorator, Component, sp } from 'cc'; const { ccclass, property } = _decorator; @ccclass('LiquidController') export class LiquidController extends Component { @property(sp.Skeleton) public skeleton: sp.Skeleton = null!; // Spine组件 private _currentAnim: string = 'idle'; start() { // 确保组件存在 if (!this.skeleton) { this.skeleton = this.getComponent(sp.Skeleton); } // 播放初始动画 this.playAnimation('idle', true); } /** * 播放Spine动画 * @param animName 动画名称 * @param loop 是否循环 * @param trackIndex 轨道索引(默认0) */ playAnimation(animName: string, loop: boolean = false, trackIndex: number = 0): void { if (this._currentAnim === animName && this.skeleton.animation) { // 避免重复播放相同动画 const state = this.skeleton.animation.getState(trackIndex); if (state && state.animation && state.animation.name === animName) { return; } } this._currentAnim = animName; // 设置动画 this.skeleton.setAnimation(trackIndex, animName, loop); // 监听动画结束事件(对于非循环动画很有用) this.skeleton.setCompleteListener((trackEntry) => { if (trackEntry.animation.name === animName) { console.log(`动画 ${animName} 播放完毕`); this.onAnimationComplete(animName); } }); } /** * 动画播放完毕回调 */ private onAnimationComplete(animName: string): void { switch (animName) { case 'pour_start': // 开始动画播完,自动循环播放倾倒动画 this.playAnimation('pour_loop', true); break; case 'pour_end': // 结束动画播完,回归空闲 this.playAnimation('idle', true); break; // ... 其他情况 } } /** * 动态切换液体颜色 * @param color 目标颜色(cc.Color) */ changeLiquidColor(color: cc.Color): void { // 方法1:直接设置整个骨骼动画的颜色(会影响所有附件) // this.skeleton.color = color; // 方法2:更精确地设置特定插槽的颜色(推荐) const slot = this.skeleton.findSlot('color_slot'); // 找到颜色插槽 if (slot) { slot.color = color; } // 如果需要,也可以同时调整高光插槽的颜色 const highlightSlot = this.skeleton.findSlot('highlight_slot'); if (highlightSlot) { const highlightColor = color.clone(); highlightColor.a = 128; // 设置半透明 highlightSlot.color = highlightColor; } } /** * 控制倾倒动画的骨骼,使其末端指向目标点(用于实时调整水流方向) * @param targetWorldPos 目标点的世界坐标 */ adjustPourBoneToTarget(targetWorldPos: cc.Vec3): void { // 1. 将目标世界坐标转换到骨骼节点的本地坐标系 const localPos = this.node.inverseTransformPoint(new cc.Vec3(), targetWorldPos); // 2. 找到水流末端骨骼(例如 pour_bone_03) const endBone = this.skeleton.findBone('pour_bone_03'); if (!endBone) return; // 3. 这里需要一些逆向运动学(IK)计算。 // 简单处理:我们可以直接设置末端骨骼的位置,但更真实的做法是设置IK约束。 // Spine运行时支持IK约束。我们可以在Spine编辑器中为水流骨骼链设置IK约束, // 然后在代码中更新IK约束的目标位置。 const ikConstraint = this.skeleton.findIkConstraint('pour_ik'); // 假设IK约束名为'pour_ik' if (ikConstraint) { // Spine的IK目标位置是相对于骨骼的本地坐标,可能需要转换 // 注意:直接设置ikConstraint.data.target位置可能不生效,需要通过动画或直接设置骨骼方式 // 更常见的做法是:在Spine中制作一段“倾倒”动画,然后我们通过代码控制播放进度和混合权重, // 或者直接使用setBonePosition来干预某根骨骼。 } // 4. 简易方案:直接设置末端骨骼的本地位置(效果可能生硬,适合要求不高的场景) // endBone.x = localPos.x; // endBone.y = localPos.y; // this.skeleton.invalidAnimationCache(); // 通知骨骼更新 } }4.3 触摸事件传递的精细控制
在“水排序”游戏中,试管节点和液体节点可能嵌套在一起。我们需要确保点击液体时,触发液体的逻辑;点击试管空白处,触发试管的逻辑。这就涉及到 CocosCreator 中事件传递的拦截。
CocosCreator 的节点事件系统是冒泡机制。默认情况下,一个触摸事件会从最底层的节点(如液体精灵)开始,然后向上层父节点(如试管节点)冒泡。
需求:当点击液体时,只响应液体的选中逻辑,阻止事件继续冒泡到试管(避免试管也被选中)。当点击试管空白处(没有液体的地方),试管才响应。
实现方案:
为液体节点添加 BlockInputEvents 组件:这是一个简单有效的方法。在液体节点的 Spine 渲染节点上,添加
BlockInputEvents组件。该组件会拦截所有输入事件,阻止其向父节点冒泡。在代码中手动控制:更灵活的方式是在事件回调中调用
event.propagationStopped = true;。
// LiquidController.ts 中 import { _decorator, Component, sp, input, Input, EventTouch, NodeEventType } from 'cc'; @ccclass('LiquidController') export class LiquidController extends Component { // ... 其他代码 onLoad() { // 监听触摸开始事件 this.node.on(NodeEventType.TOUCH_START, this.onTouchStart, this); } onTouchStart(event: EventTouch) { // 处理液体的触摸逻辑,例如高亮、开始倾倒准备 this.playAnimation('selected', false); // 阻止事件冒泡到父节点(试管) event.propagationStopped = true; // 或者,更精确的判断:可以基于触摸点是否在液体的实际渲染区域内 // 但这需要更复杂的点击检测,Spine组件本身提供了setEventListener来基于动画区域判断 } // Spine 动画事件监听(如果需要根据动画特定事件触发交互) start() { // ... this.skeleton.setEventListener((trackEntry, event) => { if (event.data.name === 'pour_hit') { // 在Spine编辑器中定义的事件名 // 水流击中目标试管,触发融合效果 this.onPourHitTarget(); } }); } }对于试管节点,它也会监听TOUCH_START事件。但由于液体节点停止了事件冒泡,只有当点击发生在试管节点自身区域且未被液体覆盖时,试管的事件才会被触发。这就完美实现了分层级的交互控制。
实操心得:在处理复杂 UI 或游戏对象交互时,清晰的事件传递层级至关重要。使用
BlockInputEvents组件是最快捷的方式,但要注意它会影响该节点所有子节点。对于需要动态改变可交互性的情况(如液体被倒空后应不可点),在代码中动态控制node.getComponent(BlockInputEvents).enabled或直接使用event.propagationStopped会更灵活。
5. 高级技巧:性能优化与问题排查
5.1 内存与渲染性能深度优化
- Spine 组件复用与对象池:游戏中会有大量相同颜色的液体产生和消失(如新关卡)。切忌频繁
instantiate和destroySpine 节点。一定要使用对象池。
// LiquidPoolManager.ts - 简单的液体对象池管理 import { _decorator, Component, Node, Pool, Prefab, instantiate } from 'cc'; const { ccclass, property } = _decorator; @ccclass('LiquidPoolManager') export class LiquidPoolManager extends Component { @property(Prefab) public liquidPrefab: Prefab = null!; private _pool: Pool<Node> = new Pool(() => { return instantiate(this.liquidPrefab); }, 10, (node: Node) => { node.removeFromParent(); }); static instance: LiquidPoolManager = null!; onLoad() { LiquidPoolManager.instance = this; } getLiquid(): Node { let liquid = this._pool.alloc(); if (!liquid.parent) { // 如果节点已被销毁,重新实例化(Pool的回调会处理) // 通常将节点添加到场景中某个管理节点下 this.node.addChild(liquid); } // 重置Spine动画状态 const skeleton = liquid.getComponent(sp.Skeleton); if (skeleton) { skeleton.setAnimation(0, 'idle', true); skeleton.setToSetupPose(); // 重置为初始姿势 } liquid.active = true; return liquid; } putLiquid(liquid: Node) { liquid.active = false; this._pool.free(liquid); } }Draw Call 合并:确保共享同一张纹理图集的 Spine 对象在渲染顺序上尽量连续,以便引擎进行 Draw Call 合并。在 CocosCreator 中,可以通过设置节点的
layer和siblingIndex来调整渲染顺序。避免将使用不同图集的节点穿插排列。动画更新频率:对于远离屏幕或不可见的液体,可以暂停其 Spine 动画的更新以节省 CPU 开销。
// 在 LiquidController 中 update(deltaTime: number) { if (this._isCulled) { // 假设有一个判断是否在视口内的标志 return; // 跳过更新 } // 或者直接控制skeleton组件 // this.skeleton.enabled = !this._isCulled; }- 控制 Spine 顶点变换数:如前所述,在 Spine 编辑器中优化网格顶点数。同时,在 CocosCreator 运行时要监控
draw call和vertices数量。可以使用引擎的Profiler工具或自定义统计。
5.2 常见问题与排查技巧实录
问题1:Spine 动画播放卡顿或闪烁。
- 排查:首先检查是否每帧都在重新设置动画(
setAnimation)。这会导致动画状态重置。确保只在状态改变时调用。其次,检查deltaTime传递是否稳定。在update中更新自定义动画逻辑时,使用director.getDeltaTime()获取稳定的时间增量。最后,在真机上用 Profiler 查看Update和Render阶段的耗时,看是否是顶点数过多或 Draw Call 过高。
问题2:Spine 动画颜色或透明度设置不生效。
- 排查:检查插槽名称是否正确。注意 Spine 中插槽的名称是大小写敏感的。确保是在动画播放前或播放后设置的插槽颜色?有些动画会覆盖插槽的颜色属性。可以尝试在 Spine 编辑器中检查该插槽的动画轨道,看是否有颜色关键帧。如果有,你需要通过代码在播放动画时动态覆盖这些关键帧值,或者修改 Spine 动画数据本身。
问题3:触摸事件点击区域不准,点击液体边缘没反应。
- 排查:CocosCreator 中 Spine 组件的点击检测默认是基于其包围盒(AABB)。如果 Spine 动画形状不规则,包围盒会很大,导致点击空白区域也有反应。反之,如果动画变形后超出了初始包围盒,点击边缘可能无反应。解决方案:
- 使用
setEventListener进行精确碰撞检测:Spine 运行时可以提供更精确的边界检测。你可以在 Spine 编辑器中为特定插槽(如液体主体)创建边界框附件(Bounding Box Attachment),然后在代码中通过skeleton.getAttachment获取并利用其顶点数据进行多边形碰撞检测。但这会带来一定的计算开销。 - 使用一个简化的碰撞体:为液体节点添加一个
PolygonCollider2D组件,并手动调整一个大致匹配液体形状的多边形。这比 Spine 的精确检测性能更好,但需要手动调整。
- 使用
问题4:多个 Spine 动画叠加时,渲染顺序错乱。
- 排查:Spine 的渲染顺序由插槽的
drawOrder决定。在 CocosCreator 中,多个sp.Skeleton组件之间的渲染顺序则由节点的层级(siblingIndex)和layer决定。如果出现一个液体的水流被另一个试管遮挡的问题,你需要确保:- 试管节点和液体节点在场景树中有正确的父子或兄弟关系。
- 合理设置节点的
zIndex属性(在 3D 渲染模式中)或siblingIndex(在 2D 中)。 - 在 Spine 内部,确保“水流”插槽的
drawOrder高于“试管”插槽(如果试管也是 Spine 制作)。
问题5:如何动态更换 Spine 皮肤(Skin)?
- 场景:除了换颜色,有时可能需要完全更换液体样式(例如,从水变成油,质感不同)。
- 解决:
// 假设我们有两个皮肤:'water' 和 'oil' this.skeleton.setSkin('oil'); this.skeleton.setSlotsToSetupPose(); // 将插槽重置为新皮肤的初始姿势 // 如果需要立即更新显示,可能需要手动设置一下动画 this.skeleton.setAnimation(0, this._currentAnim, true);6. 实战:构建“水排序”游戏核心循环
结合以上所有技术点,我们来勾勒一下游戏核心循环的代码框架:
// GameManager.ts import { _decorator, Component, Node, input, Input, EventTouch, Vec3 } from 'cc'; const { ccclass, property } = _decorator; @ccclass('GameManager') export class GameManager extends Component { @property(Node) public tubeNodes: Node[] = []; // 试管节点数组 private _selectedLiquid: LiquidController = null; // 当前选中的液体 private _selectedTubeIndex: number = -1; // 当前选中的试管索引 onLoad() { // 初始化试管,为每个试管添加TubeController组件并传递索引 this.tubeNodes.forEach((tubeNode, index) => { const tubeCtrl = tubeNode.getComponent('TubeController'); if (tubeCtrl) { tubeCtrl.init(index, this); } }); } /** * 被试管调用,当试管被点击时 */ onTubeSelected(tubeIndex: number, hitLiquid: LiquidController | null) { if (this._selectedTubeIndex === -1) { // 第一次选择:选中该试管(或其中的液体) this._selectedTubeIndex = tubeIndex; if (hitLiquid) { this._selectedLiquid = hitLiquid; this._selectedLiquid.playAnimation('selected'); } else { // 点击了试管空白处,可能无效或选中整个试管(根据游戏规则) this._selectedLiquid = null; } } else { // 第二次选择:尝试将选中试管(液体)倒入目标试管 const sourceTubeCtrl = this.tubeNodes[this._selectedTubeIndex].getComponent('TubeController'); const targetTubeCtrl = this.tubeNodes[tubeIndex].getComponent('TubeController'); if (sourceTubeCtrl && targetTubeCtrl && this._selectedLiquid) { // 判断是否可以倾倒(颜色匹配、目标试管有空位等) if (targetTubeCtrl.canReceiveLiquid(this._selectedLiquid.color)) { // 执行倾倒动画和逻辑 this.pourLiquid(this._selectedLiquid, sourceTubeCtrl, targetTubeCtrl); } else { // 无效操作,取消选中 this.cancelSelection(); } } this.cancelSelection(); } } private pourLiquid(liquid: LiquidController, source: any, target: any) { // 1. 液体播放倾倒开始动画 liquid.playAnimation('pour_start', false); // 2. 在动画播放过程中,可能需要用代码实时调整水流骨骼指向目标试管口(adjustPourBoneToTarget) // 3. 监听动画事件,在“水流命中”事件时,在目标试管创建新的液体(或修改现有液体体积) // 4. 源试管液体减少(播放缩放动画) // 5. 倾倒结束,播放收回动画 setTimeout(() => { // 这里用setTimeout模拟,实际应用动画事件回调 liquid.playAnimation('pour_end', false); target.addLiquid(liquid.color); // 目标试管增加液体 source.reduceLiquid(); // 源试管减少液体 }, 500); } private cancelSelection() { if (this._selectedLiquid) { this._selectedLiquid.playAnimation('idle', true); } this._selectedLiquid = null; this._selectedTubeIndex = -1; } }这个框架将 Spine 动画控制、事件交互和游戏逻辑串联起来。每个LiquidController负责管理自己的 Spine 动画状态,TubeController管理试管状态和点击事件,GameManager作为大脑协调全局规则和流程。
通过这样的架构,我们不仅实现了一个“水排序”游戏,更构建了一套可复用的、高效的 2D 流体动画交互方案。这套方案稍加修改,就可以应用到其他需要柔体变形、骨骼动画与复杂交互结合的游戏项目中,例如绘制线条、软体物理模拟、角色服饰飘动等。技术的价值在于其通用性和可扩展性,而 CocosCreator 与 Spine 的组合,无疑为 2D 游戏的表现力打开了新的大门。