大江湖-分析篇-角色动画实现

分析篇-角色动画与特效

摘要
​本文对游戏角色和特效的实现方式进行分析

大江湖作为 2D 像素类独立游戏,没有采用传统的序列帧动画来实现角色动作和特效,而是采用了 Spine​骨骼动画进行实现

两者对比如下:

  • 序列帧动画:传统的动画方式,需要画师一帧一帧绘制,优点是动画效果可以更细腻,缺点是工作量大成本高
  • 骨骼动画:画出一个角色后,给角色的各个组件绑上骨骼就可以动起来,添加新动作或者实现换装等功能只要替换各个组件即可实现,优缺点与序列帧动画正好相反。主流的工具有收费版的 Spine​和开源版的 dragonbones

spine 资源还原

spine3.8​之后的版本提供了将导出文件还原为原始工程的方法,可以更方便我们分析动画的制作细节

提取导出文件

spine​导出的动画文件一般包含以下几个文件,这些文件放入 unity​后,通过 spine-runtime​库进行加载和运行

  • male-01-protagonist-LEFT.png​ : 所有动画涉及的图片打包成一张图片

  • male-01-protagonist-LEFT.atlas​ : 包含了 png​中各个图片的切片信息

    •  1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      
      male-01-protagonist-LEFT.png
      size: 256,64
      format: RGBA8888
      filter: Linear,Linear
      repeat: none
      L-clothes-01
        rotate: false
        xy: 142, 4
        size: 34, 24
        orig: 34, 24
        offset: 0, 0
        index: -1
      L-head-01
        rotate: true
        xy: 82, 30
        size: 32, 44
        orig: 32, 44
        offset: 0, 0
        index: -1
  • male-01-protagonist-LEFT.json​ : 包含了骨骼/皮肤等信息

    •  1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      
      {
      "skeleton": {
      	"hash": "YFQbOucL7uz+Cn1iJIwrY/+ZyAY",
      	"spine": "3.8.84",
      	"x": -37.08,
      	"y": -11,
      	"width": 106.5,
      	"height": 81.63,
      	"images": "./images/",
      	"audio": ""
      },
      "bones": [
      	{ "name": "root" },
      	{ "name": "bone", "parent": "root", "length": 11.56, "rotation": 89.3, "x": -0.18, "y": -17.99 },
      
      ],
      "slots": [
      	{ "name": "hand-2-feng", "bone": "root" },
      ],
      "skins": [
      	{
      		"name": "role-01-protagonist",
      		"attachments": {
      			"clothes-huang": {
      				"clothes-huang": { "name": "L-clothes-01", "x": 10.97, "y": -0.6, "rotation": -90.4, "width": 34, "height": 24 }
      			},
      			"eyes-chen": {
      				"eyes-huang": { "name": "eye-01", "x": 0.32, "y": -2.86, "rotation": -0.87, "width": 18, "height": 8 },
      				"eyes-behit": { "name": "eyes-manbehit", "x": -0.59, "y": -1.3, "rotation": -0.87, "width": 18, "height": 10 }
      			},
      		}
      	}
      ],
      "events": {
      	"behit": {},
      	"seal": {}
      },
      "animations": {
      	"A01-stand": {
      		"slots": {
      			"weapon-knife": {
      				"attachment": [
      					{ "name": null }
      				]
      			},
      			"weapon-stick": {
      				"attachment": [
      					{ "name": null }
      				]
      			},
      			"weapon-sword": {
      				"attachment": [
      					{ "name": null }
      				]
      			}
      		},
      		"bones": {
      			"clothes-huang": {
      				"translate": [
      					{ "curve": 0.25, "c3": 0.75 },
      					{ "time": 0.1667, "x": 2.01, "y": 0.04, "curve": 0.25, "c3": 0.75 },
      					{ "time": 0.3333, "curve": 0.25, "c3": 0.75 },
      					{ "time": 0.5, "x": 2.01, "y": 0.04, "curve": 0.25, "c3": 0.75 },
      					{ "time": 0.6667 }
      				]
      			}
      
      		}
      	},
      
      }
      }

从项目中提取要还原的动画文件:

  • Assets/Texture2D/male-01-protagonist-TOP.png => male-01-protagonist-TOP.png
  • Assets/ScriptableObject/male-01-protagonist-LEFT_Atlas.asset => male-01-protagonist-LEFT.atlas
  • Assets/ScriptableObject/male-01-protagonist-TOP_SkeletonData.asset => male-01-protagonist-LEFT.json

纹理解包

打开 spine​工具,点击纹理解包器

​​

选择 male-01-protagonist-LEFT.atlas​文件,输出到 images​目录,点击解开

注意

​此处为何要输出到 images​目录?因为在 male-01-protagonist-LEFT.json​文件中定义了图片的根目录为 ./images

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
"skeleton": {
	"hash": "YFQbOucL7uz+Cn1iJIwrY/+ZyAY",
	"spine": "3.8.84",
	"x": -37.08,
	"y": -11,
	"width": 106.5,
	"height": 81.63,
	"images": "./images/",
	"audio": ""
},

提示解开完成,images​目录中多了很多图片文件,已经成功将图集还原成单张图片

​​​

导入数据

点击导入数据

选择 male-01-protagonist-LEFT.json​文件,点击导入

​​

此时已经可以看到完整骨骼数据了,但是发现人物画面并没有出现,原因是皮肤没有勾选

将皮肤 role-01-protagonist​前面的小圆点点亮后,发现人物和动画可以正常展示了

角色动画

prefab

角色的 prefab​存放在 Assets/Resources/prefabs/character​目录,以主角为例:

  • ​Protagonist.prefab​

    • body_top

      • Assets/Material/male-01-protagonist-TOP_Material.mat
      • Assets/Texture2D/male-01-protagonist-TOP.png
      • Assets/ScriptableObject/male-01-protagonist-TOP_Atlas.asset
      • Assets/ScriptableObject/male-01-protagonist-TOP_SkeletonData.asset
    • body_left

      • Assets/Material/male-01-protagonist-LEFT_Material.mat
      • Assets/Texture2D/male-01-protagonist-LEFT.png
      • Assets/ScriptableObject/male-01-protagonist-LEFT_Atlas.asset
      • Assets/ScriptableObject/male-01-protagonist-LEFT_SkeletonData.asset
    • body_back

      • Assets/Material/male-01-protagonist-BACK_Material.mat
      • Assets/Texture2D/male-01-protagonist-BACK.png
      • Assets/ScriptableObject/male-01-protagonist-BACK_Atlas.asset
      • Assets/ScriptableObject/male-01-protagonist-BACK_SkeletonData.asset

每个角色都制作了三个朝向的动画

  • body_top​ : 正面
  • body_left​:左面,通过 180 度翻转为右面
  • body_back​:背面视角

每一个朝向都制作了 1 个皮肤和 18 个动作

动画列表:

动画名称说明
A01-stand站立
A02-walk行走
B01-attack-sword使用剑攻击
B01-attack-sword-notuse使用剑攻击 - 未使用
B02-attack-knife使用刀攻击
B03-attack-stick使用棍攻击
B04-attack-hand拳法攻击
B05-attack~~-~~finger指法攻击
B06-attack-heart治疗
C01-attackstand-sword剑攻击-等待状态
C02-attackstand-knife刀攻击-等待状态
C03-attackstand-stick棍攻击-等待状态
C04-attackstand-hand拳法攻击-等待状态
C05-attackstand-finger指法攻击-等待状态
C06-attackstand-heart治疗-等待状态
D01-behit被攻击
D02-die死亡
D02-die-02死亡

播放动画

角色控制的代码在 Assets\Scripts\Assembly-CSharp\OhPlayerController.cs​中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
private SkeletonAnimation[] m_Animations;

private void Awake() {
    // 初始化所有骨骼动画
    this.m_Animations = base.gameObject.GetComponentsInChildren<SkeletonAnimation>(true);
    if (this.m_Animations.Length >= 3) {
        for (int j = 0; j < 3; j++) {
            this.m_Animations[j].AnimationState.Complete += new Spine.AnimationState.TrackEntryDelegate(this.State_Complete);
        }
     }
}

// 播放动画,判断朝向,播放
public void PlayAnimation(string animation, bool loop = true, bool compensate = true)
{
    if (this.m_Animations[0].AnimationName != animation)
    {
        if (!compensate)
        {
            this.m_Animations[0].skeleton.SetToSetupPose();
            this.m_Animations[0].AnimationState.ClearTracks();
        }
        this.m_Animations[0].AnimationState.SetAnimation(0, animation, loop);
    }
    if (this.m_Animations[1].AnimationName != animation)
    {
        if (!compensate)
        {
            this.m_Animations[1].skeleton.SetToSetupPose();
            this.m_Animations[1].AnimationState.ClearTracks();
        }
        this.m_Animations[1].AnimationState.SetAnimation(0, animation, loop);
    }
    if (this.m_Animations[2].AnimationName != animation)
    {
        if (!compensate)
        {
            this.m_Animations[2].skeleton.SetToSetupPose();
            this.m_Animations[2].AnimationState.ClearTracks();
        }
        this.m_Animations[2].AnimationState.SetAnimation(0, animation, loop);
    }
}


// 攻击动作播放结束后,转为站立状态
private void State_Complete(TrackEntry trackEntry) {
    if (trackEntry.Animation.Name == "B01-attack-sword")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else if (trackEntry.Animation.Name == "B02-attack-knife")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else if (trackEntry.Animation.Name == "B03-attack-stick")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else if (trackEntry.Animation.Name == "B04-attack-hand")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else if (trackEntry.Animation.Name == "B05-attack-finger")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else if (trackEntry.Animation.Name == "B06-attack-heart")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else if (trackEntry.Animation.Name == "D01-behit")
    {
        this.PlayAnimation("A01-stand", true, true);
    }
    else
    {
        bool flag1 = trackEntry.Animation.Name == "D02-die";
    }
}

核心是调用 Spine-runtime​提供的接口进行动画播放

  • skeleton.SetToSetupPose​:将骨骼重置为初始状态
  • AnimationState.ClearTracks​:将动画重置
  • AnimationState.SetAnimation(0, animation, loop)​ :设置动画效果
1
2
3
this.m_Animations[0].skeleton.SetToSetupPose();
this.m_Animations[0].AnimationState.ClearTracks();
this.m_Animations[0].AnimationState.SetAnimation(0, animation, loop);

控制角色朝向

代码较为简单,每个角色的 prefab​中都放置了不同朝向的角色实体。其中左右共用一个实体,通过设置 skeleton.ScaleX​对左右进行翻转,指定朝向时将其他朝向的实体设置为禁用状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

    public void Face(Direction direction, bool isInit = false)
    {

        if (!isInit && direction == this.m_Direction)
        {
            return;
        }

        this.m_Direction = direction;
        switch (direction)
        {
            case Direction.Left:
                this.m_Animations[0].gameObject.SetActive(false);
                this.m_Animations[1].gameObject.SetActive(true);
                this.m_Animations[1].skeleton.ScaleX = 1f;
                this.m_Animations[2].gameObject.SetActive(false);
                return;

            case Direction.Up:
                this.m_Animations[0].gameObject.SetActive(false);
                this.m_Animations[1].gameObject.SetActive(false);
                this.m_Animations[2].gameObject.SetActive(true);
                return;

            case Direction.Right:
                this.m_Animations[0].gameObject.SetActive(false);
                this.m_Animations[1].gameObject.SetActive(true);
                this.m_Animations[1].skeleton.ScaleX = -1f;
                this.m_Animations[2].gameObject.SetActive(false);
                return;
        }
        this.m_Animations[0].gameObject.SetActive(true);
        this.m_Animations[1].gameObject.SetActive(false);
        this.m_Animations[2].gameObject.SetActive(false);
      
    }

其余动画

其它的动画类型就比较简单了,都只有一个动画 animation​,自动播放或者调用接口播放即可

比如标题动画,设置了动画名称为 animation​,勾选 Loop​表示循环播放

而在 spine​设计界面,可以看到只有一个动画

调用 Spine-Runtime​接口播放

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
private void Start()
{
    base.GetComponent<MeshRenderer>().sortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
        return s.name == "EffectLayer";
    }).sortingOrder;
    this.m_Animation = base.GetComponent<SkeletonAnimation>();
    this.m_Animation.AnimationState.SetAnimation(0, this.m_Animation.skeleton.Data.Animations.Items[0], this.loopRun);
    if (!this.loopRun)
    {
        this.m_Animation.AnimationState.Complete += new Spine.AnimationState.TrackEntryDelegate(this.State_Complete);
    }
}

private void State_Complete(TrackEntry trackEntry)
{
    UnityEngine.Object.Destroy(base.gameObject);
}

0%