大家都知道,在游戏中 一般用帧动画或者骨骼动画,实现 人物的行走、奔跑、攻击等动作。
帧动画,在上一篇已经做了介绍,感兴趣的朋友可以前往阅读: CocosCreator3.8研究笔记(二十四)CocosCreator 动画系统-动画编辑器实操-关键帧实现动态水印动画效果。
今天,我们主要介绍什么是骨骼动画?Spine 是什么,骨骼动画怎么制作的?怎么使用骨骼动画?
一、什么是骨骼动画?
骨骼动画是把角色的各部分身体部件图片绑定到一根根互相作用连接的“骨骼”上,通过控制这些骨骼的位置、旋转方向和放大缩小而生成的动画。
骨骼动画比帧动画要求更高的处理器性能,但它也有更多的优势:
- 更少的美术资源: 骨骼动画的资源是一块块小的角色部件(比如:头、手、胳膊、腰等),美术再也不用提供每一帧完整的图片了,节省了资源大小。
- 更小的体积: 帧动画需要提供每一帧图片。而骨骼动画只需要少量的图片资源,并把骨骼的动画数据保存在一个 json 文件里面(后文会提到),它所占用的空间非常小。
- 更好的流畅性: 骨骼动画使用差值算法计算中间帧,这能让动画总是保持流畅的效果。
- 装备附件: 图片绑定在骨骼上来实现动画。如果需要,可以方便的更换角色的装备满足不同的需求。甚至改变角色的样貌来达到动画重用的效果。
- 不同动画可混合使用: 不同的骨骼动画可以被结合到一起。比如一个角色可以转动头部、射击并且同时也在走路。
- 程序动画: 可以通过代码控制骨骼,比如可以实现跟随鼠标的射击,注视敌人,或者上坡时的身体前倾等效果。
二、Spine 是什么
Spine是一款针对游戏的2D骨骼动画编辑工具,它具有良好的UI设计和完整的功能,是一个比较成熟的骨骼动画编辑器。
Spine旨在提供更高效和简洁的工作流程,以创建游戏所需的动画。
使用Spine创建骨骼动画分为以下步骤:
(1)、在SETUP模式下,选中Images属性,导入所需图片资源所在文件夹。
注意:路径名和资源名中不能出现中文,否则无法解析。
(2)、拖动Images下的图片到场景,对角色进行组装(把各个身体部位拼在一起),可通过Draw Order属性调整图片所在层的顺序。
(3)、创建骨骼,并绑定图片到骨骼上,要注意各骨骼的父子关系。
(4)、切换到ANIMATE模式,选中要“动”的骨骼,对其进行旋转、移动、缩放等操作,每次改动后要记得打关键帧。
(5)、在菜单栏找到Texture Packer项,对角色纹理进行打包,资源文件后缀为atlas(而非Cocos2d-x常用的plist)
打包后将生成两个文件,即:png 和 atlas。
(6)、导出动画文件Json。
感兴趣的朋友,请查看官方网站教程:Spine快速入门教程。
三、Creator 编辑器中Spine 骨骼动画的使用
Creator 中的骨骼动画资源目前支持 JSON 和 二进制 两种数据格式。
Creator 中创建骨骼动画我们需要使用 Spine Skeleton 组件。
Spine Skeleton 组件支持 Spine 官方工具导出的数据格式,并对 Spine(骨骼动画)资源进行渲染和播放。
1、Spine Skeleton 组件属性说明
属性 | 功能说明 |
CustomMaterial | 自定义材质,可用于实现溶解、外发光等渲染效果。 |
Color | 设置骨骼动画颜色 |
SkeletonData | 骨骼信息数据,拖拽 Spine 导出后的骨骼资源到该属性中 |
Default Skin | 选择默认的皮肤 |
Animation | 当前播放的动画名称 |
Animation Cache Mode | 渲染模式,包括 REALTIME(默认)、SHARED_CACHE 和 PRIVATE_CACHE 三种。 1. REALTIME 模式,实时运算,支持 Spine 所有的功能。 2. SHARED_CACHE 模式,将骨骼动画及贴图数据进行缓存并共享,相当于预烘焙骨骼动画。拥有较高性能,但不支持动作融合和动作叠加,只支持动作开始和结束事件。至于内存方面,当创建 N(N>=3)个相同骨骼、相同动作的动画时,会呈现内存优势。N 值越大,优势越明显。综上 SHARED_CACHE 模式适用于场景动画、特效、副本怪物、NPC 等,能极大提高帧率和降低内存。 3. PRIVATE_CACHE 模式,与 SHARED_CACHE 类似,但不共享动画及贴图数据,且会占用额外的内存,仅存在性能优势,如果大量使用该模式播放动画可能会造成卡顿。若想利用缓存模式的高性能,但又存在换装需求(不能共享贴图数据)时,那么 PRIVATE_CACHE 就适合你。 |
Loop | 是否循环播放当前动画 |
PremultipliedAlpha | 图片是否启用贴图预乘,默认为 True。 当图片的透明区域出现色块时需要关闭该项,当图片的半透明区域颜色变黑时需要启用该项。 |
TimeScale | 当前骨骼中所有动画的时间缩放率 |
DebugSlots | 是否显示 Slot 的 Debug 信息 |
DebugBones | 是否显示骨骼的 Debug 信息 |
DebugMesh | 是否显示 Mesh 的 Debug 信息 |
UseTint | 是否开启染色效果,默认关闭。 |
Sockets | 用于将某些外部节点挂到指定的骨骼关节上,属性的值表示挂点的数量。 |
Enable Bacth | 是否开启 Spine 合批 |
2、导入骨骼动画资源
骨骼动画所需资源有:
.json/.skel
骨骼数据.png
图集纹理.txt/.atlas
图集数据
如图,这是一个飞机的骨骼动画资源:
我们将其导入cocos creator 资源管理器中:
3、创建骨骼动画
骨骼动画创建步骤:
(1)、为节点添加 Spine Skeleton 组件
在 层级管理器 中选中需要添加 Spine Skeleton 组件的节点**,然后点击 **属性检查器下方的 添加组件 -> Spine -> Skeleton 按钮,即可添加 Skeleton 组件到节点上。
(2)、从 资源管理器 中将骨骼动画资源拖动到 属性检查器 Spine 组件的 SkeletonData 属性中。
注意:要拖动带动作的json文件,如图:
(3)、在属性检查器中,根据实际情况设置animation 、animation cache 以及 loop 等属性
注意:
(1)、当使用 Spine Skeleton 组件时,属性检查器 中 Node 组件上的 Anchor 与 Size 属性是无效的。
(2)、Spine Skeleton 组件属于 UI 渲染组件,而 Canvas 节点是 UI 渲染的 渲染根节点,所以带有该组件的节点必须是 Canvas 节点(或者是带有 RenderRoot2D 组件的节点)的子节点才能在场景中正常显示。
(3)、当使用 Spine Skeleton 组件时,由于拥有 UseTint 属性,所以其自定义材质需要有两个颜色信息,否则 Spine 的染色效果可能会出错。
四、代码中Spine 骨骼动画的使用
1、从服务器远程加载文本格式的 Spine 资源
let comp = this.getComponent('sp.Skeleton') as sp.Skeleton; let image = "http://localhost/download/spineres/test/test.png"; let ske = "http://localhost/download/spineres/test/test.json"; let atlas = "http://localhost/download/spineres/test/test.atlas"; assetManager.loadAny([{ url: atlas, ext: '.txt' }, { url: ske, ext: '.txt' }], (error, assets) => { assetManager.loadRemote(image, (error, texture: Texture2D) => { let asset = new sp.SkeletonData(); asset.skeletonJson = assets[1]; asset.atlasText = assets[0]; asset.textures = [texture]; asset.textureNames = ['test.png']; skeleton.skeletonData = asset; }); });
2、从服务器远程加载二进制格式的 Spine 资源
let comp = this.getComponent('sp.Skeleton') as sp.Skeleton; let image = "http://localhost/download/spineres/1/test.png"; let ske = "http://localhost/download/spineres/1/test.skel"; let atlas = "http://localhost/download/spineres/1/test.atlas"; assetManager.loadAny([{ url: atlas, ext: '.txt' }, { url: ske, ext: '.bin' }], (error, assets) => { assetManager.loadRemote(image, (error, texture: Texture2D) => { let asset = new sp.SkeletonData(); asset._nativeAsset = assets[1]; asset.atlasText = assets[0]; asset.textures = [texture]; asset.textureNames = ['test.png']; asset._uuid = ske; // 可以传入任意字符串,但不能为空 asset._nativeURL = ske; // 传入一个二进制路径用作 initSkeleton 时的 filePath 参数使用 comp.skeletonData = asset; let ani = comp.setAnimation(0, 'walk', true); }); });
3、加载本地 Spine 资源
import { _decorator, Component, Node, loader, sp, Label } from 'cc'; const { ccclass, property } = _decorator; @ccclass('LoadSpine') export class LoadSpine extends Component { @property({type:Label}) tips:Label| null = null; isLoadedRes = false; start () { // Your initialization goes here. loader.loadRes("spine/alien/alien-pro", sp.SkeletonData, (err, spineAsset)=> { if(err) { this.tips!.string = "Failed to load asset"; this.isLoadedRes = true; // AutoTest: Consider loading complete even if loading failed return; } let comp = this.getComponent('sp.Skeleton') as sp.Skeleton; comp.skeletonData = spineAsset!; let ani = comp.setAnimation(0, 'run', true); this.tips!.string = 'Load Success'; this.isLoadedRes = true; }); } // update (deltaTime: number) { // // Your update function goes here. // } }
4、动作控制、属性设置、事件监听
import { _decorator, CCClass, Component, sp } from "cc"; const { ccclass, property } = _decorator; @ccclass('SpineCtrl') export default class SpineCtrl extends Component{ mixTime:number= 0.2; private spine?: sp.Skeleton; private _hasStop = true; onLoad () { var spine = this.spine = this.getComponent('sp.Skeleton') as sp.Skeleton; this._setMix('walk', 'run'); this._setMix('run', 'jump'); this._setMix('walk', 'jump'); spine.setStartListener(trackEntry => { var animationName = trackEntry.animation ? trackEntry.animation.name : ""; console.log("[track %s][animation %s] start.", trackEntry.trackIndex, animationName); }); spine.setInterruptListener(trackEntry => { var animationName = trackEntry.animation ? trackEntry.animation.name : ""; console.log("[track %s][animation %s] interrupt.", trackEntry.trackIndex, animationName); }); spine.setEndListener(trackEntry => { var animationName = trackEntry.animation ? trackEntry.animation.name : ""; console.log("[track %s][animation %s] end.", trackEntry.trackIndex, animationName); }); spine.setDisposeListener(trackEntry => { var animationName = trackEntry.animation ? trackEntry.animation.name : ""; console.log("[track %s][animation %s] will be disposed.", trackEntry.trackIndex, animationName); }); spine.setCompleteListener((trackEntry) => { var animationName = trackEntry.animation ? trackEntry.animation.name : ""; if (animationName === 'shoot') { this.spine!.clearTrack(1); } var loopCount = Math.floor(trackEntry.trackTime / trackEntry.animationEnd); console.log("[track %s][animation %s] complete: %s", trackEntry.trackIndex, animationName, loopCount); }); spine.setEventListener(((trackEntry:any, event:any) => { var animationName = trackEntry.animation ? trackEntry.animation.name : ""; console.log("[track %s][animation %s] event: %s, %s, %s, %s", trackEntry.trackIndex, animationName, event.data.name, event.intValue, event.floatValue, event.stringValue); }) as any); this._hasStop = false; } // OPTIONS toggleDebugSlots () { this.spine!.debugSlots = !this.spine?.debugSlots; } toggleDebugBones () { this.spine!.debugBones = !this.spine?.debugBones; } toggleDebugMesh () { this.spine!.debugMesh = !this.spine?.debugMesh; } toggleUseTint () { this.spine!.useTint = !this.spine?.useTint; } toggleTimeScale () { if (this.spine!.timeScale === 1.0) { this.spine!.timeScale = 0.3; } else { this.spine!.timeScale = 1.0; } } // ANIMATIONS stop () { this.spine?.clearTrack(0); this._hasStop = true; } walk () { if (this._hasStop) { this.spine?.setToSetupPose(); } this.spine?.setAnimation(0, 'walk', true); this._hasStop = false; } run () { if (this._hasStop) { this.spine?.setToSetupPose(); } this.spine?.setAnimation(0, 'run', true); this._hasStop = false; } jump () { if (this._hasStop) { this.spine?.setToSetupPose(); } this.spine?.setAnimation(0, 'jump', true); this._hasStop = false; } shoot () { this.spine?.setAnimation(1, 'shoot', false); } idle () { this.spine?.setToSetupPose(); this.spine?.setAnimation(0, 'idle', true); } portal () { this.spine?.setToSetupPose(); this.spine?.setAnimation(0, 'portal', false); } // _setMix (anim1: string, anim2: string) { this.spine?.setMix(anim1, anim2, this.mixTime); this.spine?.setMix(anim2, anim1, this.mixTime); } }