从dark-slash中学习游戏动画

最近在学习游戏开发中的动画效果(主要是技能效果),苦于找不到合适的资料,发现cocos官方提供了一个dark-slash暗黑斩的教学项目,这是一个比较完整的游戏项目,也包含了不少动画项目,很适合新手学习,本文主要整理了阅读该项目的一些收获。

<!--more-->

学习目标

  • 如何实现动画
  • 如何在项目中管理动画

1. 一些文档中的概念

1.1. 节点和组件

Cocos Creator 的工作流程是以组件式开发为核心的,组件式架构也称作 组件 — 实体系统(或 Entity-Component System),简单的说,就是以组合而非继承的方式进行实体的构建。

节点(Node)是承载组件的实体,提供了位置、位移、旋转等属性,此外通过将具有各种功能的 组件(Component) 挂载到节点上,来让节点具有各式各样的表现和功能。

不同类型的节点挂载的基础组件不同,比如Sprite节点,表示由Sprite组件负责提供功能,可以配置Sprite frame、Blend等

一个节点可以挂载过个组件,但是只能包含一个渲染组件(渲染组件指Sprite、Label等基础组件)

开发者接触最多的是Script脚本组件。

  • 通过this.node 获取当前组件对应的节点
  • 通过this.getComponent(组件名)获取其他的组件
  • 可以通过配置properties cc.Node 然后赋值的方式获取其他的节点,需要将对应的节点拖拽到这个属性上
  • 可以通过配置properties 组件类型 然后赋值的方式获取其他的组件,需要将挂载了该组件的节点拖拽到这个属性上
  • 可以通过this.node.childrenthis.node.getChildByName等方式获取子节点

1.2. 节点树结构

我们修改节点的 位置(Position) 属性设定的节点位置是该节点相对于父节点的 本地坐标系 而非世界坐标系。最后在绘制整个场景时 Cocos Creator 会把这些节点的本地坐标映射成世界坐标系坐标。

锚点决定了节点以自身约束框中的哪一个点作为整个节点的位置,就是用来控制子节点在父节点的位置。

锚点位置确定后,所有子节点就会以 锚点所在位置 作为坐标系原点。

推荐的目录结构

  • Canvas 节点是我们推荐大家使用的 渲染根节点,这个的意思就是希望大家将所有渲染相关的节点都放在 Canvas 下面,其默认锚点是 (0.5, 0.5),所以 Canvas 下的节点会以屏幕中心作为坐标系的原点。根据经验,这样的设置会简化场景和 UI 的设置
  • 除了有具体图像渲染任务的节点之外,我们还会有一部分节点只负责挂载脚本,执行逻辑,不包含任何渲染相关内容。通常我们将这些节点放置在场景根层级,和 Canvas 节点并列,这样方便协作的时候其他开发者能够第一时间找到游戏逻辑和进行相关的数据绑定

因此可以看见dart-slash项目的节点层级结构

1.3. 节点事件

Button组件除了在代码中手动声明事件外,也可以在编辑器中通过Click Events注册事件

比如StartGame游戏场景中的开始游戏按钮

1.4. 对象池

cocos 文档

在运行时进行节点的创建(cc.instantiate)和销毁(node.destroy)操作是非常耗费性能的,因此在复杂的场景中,一般在初始化之前创建节点,在切换场景时才销毁节点。在运行时动态获取和回收节点需要借助对象池

2. 项目结构

2.1. 目录

  • animation存放动画clip,其中按照预制体分组创建二级目录管理clip
  • atlas存放图集,类似于合并后的精灵图,通过plist文件读取单个精灵图
  • audio 音频
  • font 字体
  • migration 升级cocos creator的文件,可以不用管
  • particles 粒子文件
  • prefab,预制体
  • scripts,脚本组件,其中按Actors、Render、UI等进一步细分
  • textures,单张大图,如游戏背景图等

2.2. 代码命名

看见不少以efx_xx命名的节点,查了下应该是EFX效果的意思,如efx_light表示光线效果、efx_flare表示闪耀效果

foe指的是敌人,我之前一直喜欢用enemy,看起来foe字符更少

3. 动画系统

参考:Animation 官方文档

3.1. 复活动画

以英雄的复活动画为例

看起来的步骤是

  • 构建一个节点树,向节点树根节点添加Animation组件,创建一个clip
  • 在动画编辑器中,编辑clip,实际上是对节点树的每个参与动画的节点进行动画编辑
  • 每个参与动画的节点,可以设置相关的动画属性,比如关键帧、opacity、scale等
  • 每个参与动画的节点,会在指定的某帧开始运行动画
  • 这样,当游戏渲染时,就可以看见多个动画节点所播放出来的连续帧动画

再比如StartGame的游戏场景,整个动画都挂载到menuAnim这个节点下,控制不同的节点动画来实现的

如果需要作出比较酷炫的动画,感觉需要

  • 一堆精心制作的素材
  • 有比较强的动画设计感,正确设置那些需要承担动画效果的节点

这两点需要刻意大量练习才行,也许可以交给美术帮忙实现,程序只用在正确的时间play相关的动画即可。

3.2. 练习:单节点的动画

单节点动画比较简单,下面以一个节点的缩放动画为例

为节点添加一个动画组件,创建一个clip,然后点击编辑

添加一个动画属性,这里选择scale

设置帧数据,在第1帧设置scale为0,在第5帧设置scale为1

点击预览,就可以看见动画效果了。

脚本操作播放动画

// block为对应的节点
const anim = this.block.getComponent(cc.Animation);
if(anim) {
    // clip1为刚才创建的动画clip
    anim.play('clip1');
} 

跟在DOM中向一个div添加动画类比较类似。

dom.classList.add("clip1")

3.3. 练习:多个节点的动画

多个节点之间组合形成动画,比如现在要实现一个动画:两个节点从上下向中间位移靠拢

首先创建节点树,添加Animation组件

编辑clip,对于每个子节点而言,均选择y作为动画属性,然后插入关键帧

对于每个节点而言,都有两个关键帧,最后效果如下

点击运行,就可以看见效果了。

4. 敌人perfab

敌人是游戏中需要动态创建的一个资源

单个perfab的结构大概是

  • fxBlood,用于播放受攻击时的血液效果
  • sprite,贴图,挂载行走等动画
  • blade,用于播放受攻击时的斩击效果
  • fxSmoke,烟雾粒子效果

除了受击时需要fxBlood和blade这两个额外的节点来展示动画时,常规的动画都在sprte节点上通过frame帧动画来实现,

帧动画包括

  • run_right、run_up、run_down 根据移动方向展示行动动画
  • stand 站立、dead 受到攻击死亡、corpse 倒地尸体
  • pre_atk_up、pre_atk_down、pre_atk_right 根据攻击方向展示对应动画

这里需要准备一些贴图,然后循环播放就行。

每个敌人perfab挂载了两个脚本组件:MoveFoe

4.1. 移动动画

Move,一个用来播放移动动画的状态机,根据MoveState播放不同的帧动画,这里一个比较有意思的地方是通过scaleX 播放run_right来实现向左移动,节省了一个run_left的动画

4.2. 攻击动画

Foe,用来配置基础属性和逻辑,包括Hp、攻击距离等属性,以及attack、dead的等逻辑。

来看看攻击相关的逻辑

  • 在update的时候判断与玩家的距离,如果dist < this.atkRange,则执行prepAttack
let dir = this.player.node.position.sub(this.node.position);
let rad = Math.atan2(dir.y, dir.x);
let deg = cc.misc.radiansToDegrees(rad);
if (dist < this.atkRange) {
    this.prepAttack(dir);
    return;
}
  • prepAttack中根据角度执行pre_atk_right等不同方向的预攻击动画,同时开启定时器,播放完毕后执行attack
let animName = '';
if (Math.abs(dir.x) >= Math.abs(dir.y)) {
    animName = 'pre_atk_right';
} else {
    if (dir.y > 0) {
        animName = 'pre_atk_up';
    } else {
        animName = 'pre_atk_down';
    }
}
this.node.emit('freeze');
this.anim.play(animName);
this.isMoving = false;
this.scheduleOnce(this.attack, this.atkPrepTime);
  • attack中,处理待攻击位置targetPos,然后执行attackOnTarget
let deg = cc.misc.radiansToDegrees( cc.v2(0, 1).signAngle(atkDir) );
let angleDivider = [0, 45, 135, 180];
let slashPos = null;
// 根据攻击角度,设置当前节点的图片帧
function getAtkSF(mag, sfAtkDirs) {
    // ...
}
this.spFoe.spriteFrame = getAtkSF(mag, this.sfAtkDirs);

let delay = cc.delayTime(this.atkStun);
let callback = cc.callFunc(this.onAtkFinished, this);

// 近战
if (this.atkType === AttackType.Melee) {
    // 播放移动动作
    let moveAction = cc.moveTo(this.atkDuration, targetPos).easing(cc.easeQuinticActionOut());
    this.node.runAction(cc.sequence(moveAction, delay, callback));
    this.isAttacking = true;
} else {
    // 根据projectileType创建远程攻击的元素如箭矢、火球
    if (this.projectileType === ProjectileType.None) {
        return;
    }
    this.waveMng.spawnProjectile(this.projectileType, this.node.position, atkDir);
    this.node.runAction(cc.sequence(delay, callback));
}

4.3. 箭矢

箭矢是一种比较特殊的预制体,由弓箭手射出,朝对应方向前进,给闪击之后会断裂消失;碰到玩家之后会造成伤害。

使用brokenFX节点来实现断裂动画,该节点还挂载了一个AnimHelper组件,用于处理在动画结束之后执行一些回调,这里是在动画结束后回收资源。

由于游戏存在不同种类的敌人(从Foe0到Foe6),每种敌人都包括类似的动画,但是帧图片不同,可见制作敌人prefab是一个比较重复和耗费劳动力的地方。

4.4. 小结

花了两天时间,最大的收获就是cocos creator动画系统的使用,之前一直不太了解动画编辑器是如何工作的,现在终于明白了:不论多么复杂的动画动画,都可以将其拆分成某些小的动画,然后交给单个节点独立完成。

(当然,如何做出酷炫的动画,需要依赖很多方面的知识,感受到了游戏开发中程序员的渺小。

此外,该项目对于游戏逻辑管理、事件绑定、对象池也有比较好的例子,是一个非常值得新手学习的项目。