游戏角色动画:从入门到商用(一)

2D游戏中的角色由两种方案,第一种是骨骼动画,骨骼动画的好处是节省资源,减少空间占用;但是缺点是表现力差,一般只做侧面2方向,主要用于横板过关类的游戏。

第二种是逐帧动画,逐帧动画理论上来将可以做任意多个方向,但每1个方向就是1套序列帧,会占用大量的内存,因此一般是采用1方向,4方向和8方向。其中1方向的一般是npc,只是正面朝向玩家,4方向和8方向的一般位普通角色,细节要求不高的话,可以利用翻转节省对称方向的资源。传统的经典2D游戏,梦幻,大话,神武,传奇等都是采用这种方式实现的。

一 动画资源设计

  • 方向(5方向模拟8方向)
    • 上:0
    • 右上:1
    • 右:2
    • 右下:3
    • 下:4
    • 左下(右上1翻转):5
    • 左(右2翻转):6
    • 左上(右下3翻转):7
  • 换装
    • 身体
    • 武器
    • 翅膀
    • 其他
  • 动作:
    • idle 空闲
    • war 战斗状态
    • run 跑步
    • die 死亡
    • struck 受击
    • spell 持续施法
    • appear 出现
    • disappear 消失
    • sneak 潜行
    • attack 普攻
    • skill0、skill1、skill2、… 技能动作
  • 输出资源命名:
    • 目录命名:角色编号,角色编号由6位组成,包括:
      • 类型2位,如:
        • 10 表示角色
        • 20 小怪
        • 30 boss
        • 40 npc
      • 职业2位
        • 01 战士
        • 02 法师
        • 03 道士
      • 序号2位:从00开始递增,表示该角色的资源数
    • 图片资源命名:部位_角色编号_动作名_方向_帧序号
      • 部位:
        • 身体:body
        • 武器:weapon
        • 翅膀:wing
      • 角色编号:即目录名
      • 动作名,idle、run 等
      • 方向:1位,0-7
      • 帧序号:2位,从00开始,按播放顺序递增

二 编辑器实现帧动画

介绍下 demo 环境:

  • 系统 win10
  • 游戏引擎 cocos creator 2.4
  • 脚本语言 typescript

首先入门版,使用 cocos creator 动画编辑器,做一个帧动画,为了节省时间,只做下角色身体 body 空闲动作 idle 下方向 4 的动画。cocos create 帧动画制作参考文档,通过以下步骤:

  1. 导入帧动画资源
  2. 添加一个 Sprite 节点
  3. 在 Sprite 节点添加 Animation 组件
  4. 创建动画剪辑 idle,添加 cc.Sprite.spriteFrame 属性
  5. 设置每个关键帧的纹理
  6. WrapMode 改为 Loop
  7. 将新建的 Clip 设为 Default Clip
  8. 勾选 Play On Load
  9. 运行场景

游戏角色动画:从入门到商用(一)_第1张图片
这样不需要任何代码,就可以快速在场景中制作一个角色的帧动画。

三 动态创建动画

一般游戏角色都会有很多动作,如果每个动作的动画都需要在编辑器中编辑实现,会耗费大量时间。因此需要使用代码创建帧动画,参考文档,动态添加动画组件,创建动画剪辑。

  1. 在脚本中声明一个 Sprite 变量 spPlayer,保存玩家节点
  2. 声明一个 cc.SpriteFrame 变量 spPlayerFrames,保存帧动画每帧的纹理
  3. 在编辑器中创建一个精灵,对上述两个变量赋值
  4. 使用代码创建动画剪辑,然后循环播放动画

游戏角色动画:从入门到商用(一)_第2张图片

    @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;// 循环播放
    }

代码中,动态创建了动画剪辑,然后播放动态创建的动画剪辑。修改之后,将动画创建从编辑器移到了代码中,此时需要运行之后才能看到效果。

四 动态加载资源

上述过程中动画虽然动态创建了,但是精灵帧还是手动从编辑器中拖,将精灵帧也改为动态加载。

  1. 删除关联的中的 spPlayer 节点和 spPlayerFrames 序列帧
  2. 动态创建角色节点 ndPlayer
  3. 动态加载序列帧动画资源到 spPlayerFrames 中
  4. 使用代码创建动画剪辑
  5. 循环播放动画
    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,然后在代码中动态加载图集,获取其中的精灵帧创建动画。步骤如下:

  1. 使用 TexturePacker 将动画序列帧打包成图集
  2. 动态创建角色节点 ndPlayer
  3. 动态加载图集
  4. 用图集中的精灵帧创建动画剪辑
  5. 循环播放动画

导出图集资源:
游戏角色动画:从入门到商用(一)_第3张图片

    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 的资源,本节把角色身体的全部动作和方向的动画都加载进游戏。

  1. 打包多图集资源:
    • 在 TexturePacker 中勾选 Multipack
    • 最大尺寸 Max-size 设为 1024,尺寸限制 Size Constraints 改为 AnySize
    • 修改导出 png 和 plist,在后缀前加上 -{n},例如 player-{n}.pngplayer-{n}.plist
    • 生成图集,可以看到命名是 player-0.png、player-0.plist, player-1.png、player-1.plist等
  2. 动态创建角色节点 ndPlayer
  3. 动态加载多个图集
  4. 根据动作和方向,缓存图集中的精灵帧
  5. 创建动画剪辑
  6. 循环播放动画
  7. 载界面上添加两个按钮
  8. 在按钮的响应事件中,分别切换动画方向和动作

TexurePacker设置:
游戏角色动画:从入门到商用(一)_第4张图片
导出资源:
游戏角色动画:从入门到商用(一)_第5张图片
编辑器:
游戏角色动画:从入门到商用(一)_第6张图片

    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方向,新增了翻转。
游戏角色动画:从入门到商用(一)_第7张图片
回顾以下,每一小节都做了什么:

  1. 编辑器中创建了帧动画
  2. 动画创建帧动画
  3. 动态加载每帧资源
  4. 将动画资源打成单图集
  5. 加载多图集

一般小游戏到这一步就差不多了,后面自己进一步封装一些功能就可以了。如果游戏比较重度,那么还需要进一步的优化,且看游戏角色动画:从入门到商用(二)。

你可能感兴趣的:(游戏开发,游戏开发,游戏角色,游戏)