2D游戏中的角色由两种方案,第一种是骨骼动画,骨骼动画的好处是节省资源,减少空间占用;但是缺点是表现力差,一般只做侧面2方向,主要用于横板过关类的游戏。
第二种是逐帧动画,逐帧动画理论上来将可以做任意多个方向,但每1个方向就是1套序列帧,会占用大量的内存,因此一般是采用1方向,4方向和8方向。其中1方向的一般是npc,只是正面朝向玩家,4方向和8方向的一般位普通角色,细节要求不高的话,可以利用翻转节省对称方向的资源。传统的经典2D游戏,梦幻,大话,神武,传奇等都是采用这种方式实现的。
介绍下 demo 环境:
首先入门版,使用 cocos creator 动画编辑器,做一个帧动画,为了节省时间,只做下角色身体 body 空闲动作 idle 下方向 4 的动画。cocos create 帧动画制作参考文档,通过以下步骤:
这样不需要任何代码,就可以快速在场景中制作一个角色的帧动画。
一般游戏角色都会有很多动作,如果每个动作的动画都需要在编辑器中编辑实现,会耗费大量时间。因此需要使用代码创建帧动画,参考文档,动态添加动画组件,创建动画剪辑。
@property(cc.Node)
ndActor: cc.Node = null;// 角色节点
@property([cc.SpriteFrame])
spPlayerFrames: cc.SpriteFrame[] = [];
animation: cc.Animation = null;
onLoad() {
let clipName: string = "idle"; // 动画剪辑名字,播放动画时使用
let sample: number = 6; // 帧率,即一秒钟播放多少帧
this.ceateAnimation(clipName, sample, this.spPlayerFrames);
this.playAnimation(clipName, cc.WrapMode.Loop);
}
ceateAnimation(clipName: string, sample: number, spriteFrames: cc.SpriteFrame[]) {
// 创建动画剪辑
let animation = this.ndActor.addComponent(cc.Animation);
let clip: cc.AnimationClip = cc.AnimationClip.createWithSpriteFrames(spriteFrames, sample);
animation.addClip(clip, clipName);
}
playAnimation(clipName: string, mode: cc.WrapMode) {
// 播放动画
let aniState: cc.AnimationState = this.ndActor.getComponent(cc.Animation).play(clipName);// 播放动画
aniState.wrapMode = mode;// 循环播放
}
代码中,动态创建了动画剪辑,然后播放动态创建的动画剪辑。修改之后,将动画创建从编辑器移到了代码中,此时需要运行之后才能看到效果。
上述过程中动画虽然动态创建了,但是精灵帧还是手动从编辑器中拖,将精灵帧也改为动态加载。
ndActor: cc.Node = null; // 角色节点
spPlayerFrames: cc.SpriteFrame[] = []; // 保存加载的帧动画资源
loadIndex: number = 0; // 已加载下标
loadTotalCnt: number = 6; // 动画帧数
animation: cc.Animation = null; // 角色动画组件
onLoad() {
this.ndActor = this.createActorNode();
this.loadSpriteFrames();
}
loadSpriteFrames() {
// 加载帧动画资源
if (this.loadIndex >= this.loadTotalCnt) {
this.ceateAnimation();
return;
}
let url = "demo/piece/100354/body_100354_idle_4_0" + this.loadIndex;
cc.resources.load(url, cc.SpriteFrame, (error: Error, spriteFrame: cc.SpriteFrame) => {
if (error) {
console.error(error.message || error);
}
this.loadIndex += 1;
this.spPlayerFrames.push(spriteFrame);
this.loadSpriteFrames();
});
}
ceateAnimation() {
// 资源加载完成,播放动画
let clipName = "idle"; // 动画剪辑名字,播放动画时使用
let sample = 6; // 帧率,即一秒钟播放多少帧
this.createAnimationClip(clipName, sample, this.spPlayerFrames);
this.playAnimation(clipName, cc.WrapMode.Loop);
}
createActorNode() {
// 创建角色节点
let node = new cc.Node();
node.parent = this.node;
node.addComponent(cc.Sprite);
node.addComponent(cc.Animation);
return node;
}
代码中新增了方法 loadSpriteFrames,动态的从图片中加载动画纹理。经过这两步,将动画创建完全从编辑器移到了代码中。
一般游戏中将会有很多帧动画资源,为了降低 Draw Call,优化 IO,需要将帧动画得资源打成一个图集,打图集一般使用得是 TexurePacker,然后在代码中动态加载图集,获取其中的精灵帧创建动画。步骤如下:
loadSpriteFrames() {
let url = "demo/sheet/simple/100354";
cc.resources.load(url, cc.SpriteAtlas, (error: Error, spriteAtlas: cc.SpriteAtlas) => {
if (error) {
console.error(error.message || error);
return;
}
this.spPlayerFrames = spriteAtlas.getSpriteFrames();
this.spPlayerFrames.sort();
this.ceateAnimation();
});
}
代码跟上一节的差不多,只改了 loadSpriteFrames 这个方法,精灵帧由从图片一张一张加载,改到从图集中一次性加载。
一个角色有多个动作,每个动作又有不同方向。在打图集时,为了兼容性,一般最大尺寸都设置成 1024 * 1024,因此一个角色就会产生多张图集。之前的都只加载了 idle 动作方向 4 的资源,本节把角色身体的全部动作和方向的动画都加载进游戏。
Multipack
Max-size
设为 1024
,尺寸限制 Size Constraints
改为 AnySize
-{n}
,例如 player-{n}.png
、player-{n}.plist
ndActor: cc.Node = null; // 角色节点
spPlayerFrameMap: {[actionName: string]: {[actionName_dir: string]: cc.SpriteFrame[]}} = {}; // 保存加载的帧动画资源
loadIndex: number = 0; // 已加载下标
loadTotalCnt: number = 5; // 图集数量
dir: number = 4; // 方向
actions: string[] = [];// 动作列表
actionIndex: number = 0;// 动作下标
onLoad() {
this.ndActor = this.createActorNode();
this.loadSpriteFrames();
}
loadSpriteFrames() {
// 加载帧动画资源
if (this.loadIndex >= this.loadTotalCnt) {
// 排序以保证帧序列顺序
for (let actionName in this.spPlayerFrameMap) {
if (this.actions.indexOf(actionName) < 0) {
this.actions.push(actionName);
}
for (let dir in this.spPlayerFrameMap[actionName]) {
this.spPlayerFrameMap[actionName][dir].sort();
let frames = this.spPlayerFrameMap[actionName][dir]
this.createAnimationClip(actionName, parseInt(dir), frames.length, frames);
}
}
let actionName = "idle";
this.actionIndex = this.actions.indexOf(actionName);
this.playAnimation(actionName, this.dir, cc.WrapMode.Loop);
return;
}
let url = "demo/sheet/plist/100354-" + this.loadIndex;
cc.resources.load(url, cc.SpriteAtlas, (error: Error, spriteAtlas: cc.SpriteAtlas) => {
if (error) {
console.error(error.message || error);
return;
}
this.onLoadAtlas(spriteAtlas);
this.loadIndex += 1;
this.loadSpriteFrames();
});
}
onLoadAtlas(spriteAtlas: cc.SpriteAtlas) {
let frames = spriteAtlas.getSpriteFrames();
let name, actionName, dir;
for (let i = 0; i < frames.length; i++) {
name = frames[i].name.split("_");
actionName = name[2]
if (!this.spPlayerFrameMap[actionName]) {
this.spPlayerFrameMap[actionName] = {};
}
dir = name[3]
if (!this.spPlayerFrameMap[actionName][dir]) {
this.spPlayerFrameMap[actionName][dir] = [];
}
this.spPlayerFrameMap[actionName][dir].push(frames[i]);
}
}
createActorNode() {
// 创建角色节点
let node = new cc.Node();
node.parent = this.node;
node.addComponent(cc.Sprite);
node.addComponent(cc.Animation);
return node;
}
createAnimationClip(actionName: string, dir: number, sample: number, spriteFrames: cc.SpriteFrame[]) {
// 创建动画剪辑
let clipName = actionName + "_" + dir;
let clip = cc.AnimationClip.createWithSpriteFrames(spriteFrames, sample);
this.ndActor.getComponent(cc.Animation).addClip(clip, clipName);
}
playAnimation(actionName, dir, mode) {
let real_dir = this.getRealDir(dir);
let clipName = actionName + "_" + real_dir;
this.ndActor.scaleX = dir < 5 ? 1 : -1;
// 播放动画
let aniState = this.ndActor.getComponent(cc.Animation).play(clipName);// 播放动画
aniState.wrapMode = mode;// 循环播放
}
getRealDir(dir) {
if (dir < 5) {
return dir;
} else if (dir === 5) {
return 3;
} else if (dir === 6) {
return 2;
} else if (dir === 7) {
return 1;
} else {
return this.getRealDir(dir % 8);
}
}
onClickDir() {
this.dir = (this.dir + 1) % 8;
this.playAnimation(this.actions[this.actionIndex], this.dir, cc.WrapMode.Loop);
}
onClickAction() {
this.actionIndex = (this.actionIndex + 1) % this.actions.length;
this.playAnimation(this.actions[this.actionIndex], this.dir, cc.WrapMode.Loop);
}
图集的加载,从上一节中的1张,变成了多张。因为这一节新增了多个动作和方向,加载过程中将所有精灵帧通过动作和方向,存在spPlayerFrameMap中,加载完成之后创建动画剪辑。因为是5方向模拟8方向,新增了翻转。
回顾以下,每一小节都做了什么:
一般小游戏到这一步就差不多了,后面自己进一步封装一些功能就可以了。如果游戏比较重度,那么还需要进一步的优化,且看游戏角色动画:从入门到商用(二)。