射箭小游戏:Unity之动画系统
前排提示:
本文主要涉及Unity动画系统中动画状态机的基础以及简单的混合树知识。
下面是一些将会用到的免费资源:
游戏内容相关资源(必要):
环境相关资源(非必要):
- ☞超多免费天空盒 AllSky Free
- ☞红木森林 Dream Forest Tree
- ☞红红火火花花草草 Grass Flowers Pack Free
- ☞简易木制建筑与各种装饰 Shed, Tools, Bridge and Fences
游戏内容设计
- 地形:使用地形组件,上面有草、树
- 天空盒:使用天空盒,天空可随时间变化切换
- 固定靶:有一个以上固定的靶标
- 运动靶:有一个以上运动靶标,运动轨迹,速度使用动画控制
- 射击位:地图上应标记若干射击位,仅在射击位附近可以拉弓射击
- 驽弓动画:支持蓄力,然后保持待击发状态,择机射击
- 游走:玩家的驽弓可在地图上游走,不能碰上树和靶标等障碍
- 碰撞与计分:在射击位,射中靶标的相应分数,规则自定
弩弓动画设计
在Package Manager窗口中导入弩弓模型后,可以在Assets文件中看到一个名为"RyuGiKen"的文件夹,这就是弩弓资源的文件夹,打开其中的Animations文件夹,可以看到只有一个名为"CrossBow"的动画控制器(Animator Controller),这个控制器就是用来控制弩弓的动画的状态机,可以在Animator窗口中查看其内部各种状态、逻辑转换和参数三大要素,如下图所示:
动画状态机三大要素
参数
- 一共有Int、Float、Bool和Trigger四种类型可选
- 常用于状态机内部与外部交换参数信息,并用于状态机状态转换以及进行某些计算
- Bool和Trigger,前者不会 从True自动恢复为False,后者会从触发态自动恢复为待触发态
状态
- 每个状态都代表一个动画(Animation Clip),可以从窗口中拖入一个动画创建新状态
- 状态机进入一个状态后会自动执行每个状态代表的动画,可以在状态的监视窗口(Inspector)中调整相关选项,如动画播放速度
- 状态机固定从Entry状态开始,进入End状态(非必须)则会结束状态机并重新开始
- 状态机可以在任意状态下转到Any State,Any State只能扇出而不能扇入,又或者,Any State状态可以认为是一个宏定义,Unity会自动将每个从Any State扇出的边的起始状态更换为所有其它状态(不包括扇出后的状态)
状态转换
在某个状态下,满足某条边的全部(And) 特定条件(Conditions)时将沿这条边进行状态转换,如果需要选择条件(Or) 则需要从状态中拉出多条边,分别设置每条边的转换条件,这可以在每个状态的监视窗口中选择不同的转换(Transitions)进行设置来实现
状态转换过程中也可以播放动画,具体而言需要勾选择机退出(Has Exit Time)选项,主要作用是让原动画再多播放一段(可能不完整),并择机退出从而实现动画的连续过渡
从Entry扇出的转换中至少有一个默认转换(Default Entry Transition),可以在Entry状态上右键并选择"Set StateMechine Default State"来设置
有兴趣深入研究的请自行搜索相关文档以及教学资源。
混合树
回到弩弓状态机上,通过查看"CorssBow"各个状态的动画,可以得知默认的控制器实际上只有三种状态,当满足Fire=true状态后状态机将会从Empty转到Fill,这个状态主要是为了播放一个装填的动画,播放完毕后直接转到Hold状态,满足Fire=false后转到Shoot状态播放射箭的动画,然后转到Empty或者Fill(如果仍然满足Fire条件的话)。但是这里的Fill是完全蓄力的动画,Hold也是完全蓄力的状态,很明显这不符合半蓄力的目标,由于不可能为每一个不同的蓄力程度制作不同的动画,因此需要一种方法,它能够根据初始态和结束态自动插值计算出中间的过渡态,这时需要请出另一个大佬:混合树(Blend Tree)。
在状态机中先删除原有的Hold以及Fill,因为它们都不是我们想要的效果,然后右键,选择Create State => From New Blend Tree,可以给它起个名字,这里就叫"Hold"。然后双击它进入混合树,可以看到只有一个孤零零的状态(混合态),它还有一个带名字"Blend"的滑槽,实际上代表了这颗混合树使用的参数名,此时可以在左侧参数列表中看到新增的变量(默认名为"Blend"),在监视窗口的"Parameter"选项中我们可以修改其绑定的变量。
点击混合树,可以在监视窗口中看到一个名为Motion的列表,当我们点击'+'号并选择Add Motion Field时,列表中多出了一个空行,它代表了要用于混合的状态,这里需要两个Motion,分别选择"Hold"动画以及"Empty"动画(上面已经讲过了,状态与动画是1对1关系),可以看到此时混合树分出了两个枝干,滑动滑槽,可以在预览中看到动画会在"Hold"和"Empty"的状态之间过渡,由此可以通过这个"混合参数"来表示蓄力程度。
实际上一棵混合树最多可以接受两个变量,并且它们的类型都必须是Float,这些变量决定了当前状态在状态空间中的坐标 ,每个状态在状态空间中都有一个坐标,Unity正是通过计算当前状态与各个状态之间的距离来计算混合后的动画,这部分内容这里就不涉及了。
回到正题上来,这棵混合树到这里就算完成了,点击上方的"Base Layer"回到原来含有"Entry"的层次,可以看到现在支离破碎的状态机,此时就要重建状态机的转换逻辑,在每个状态上右键并选择"Make Transition"就可以拉出一个新的转换逻辑,注意箭头的方向标识转换的方向,并补全每个转换的条件,这里展示一下最后的结果:
这里将"Empty"状态也去掉了,它已经可以通过混合树表示因此已经不再需要了,并且将从Hold状态转为Shoot状态的转换条件设为了一个新的Trigger类型变量"Fire",这样就不需要考虑手动恢复了。至此,弩弓动画的设计已经完成了,十分简单吧!
弩弓控制
搞定了弩弓动画,接下来就能愉快地写代码了,让它能够根据输入完成蓄力、保持与射击动作。
以下是弩弓的控制的核心代码,将它放在任意一个游戏物体上(强烈建议放在角色身上),并配置好Arrow和NewArrow,前者是与弩弓绑定的用于动画播放的箭,后者是包自带的箭的预制件,注意这里为发射时创建的箭添加了标签"WithTarget",主要是为了在后面与标靶发生碰撞时,能够让箭与标靶进行绑定,实现箭与移动靶一同移动的效果。
C#
//PullCrossBow.cs
using UnityEngine;
public class PullCrossBox : MonoBehaviour
{
...
void Update()
{
if(Input.GetKey(KeyCode.R)) {
//CanPull表示是否位于射击位
if(!CanPull)
return;
//进行蓄力,恢复箭的渲染
if(!renderer.enabled)
renderer.enabled = true;
Strength += 0.01f;
Strength = Strength > 1 ? 1 : Strength;
gameObject.GetComponent<Animator>().SetFloat("Power", Strength);
State = 1;
} else
{
//鼠标左键,射击
if(Input.GetMouseButtonDown(0) && State == 2 && CanPull) {
Force = Strength;
Strength = 0;
State = 3;
gameObject.GetComponent<Animator>().SetTrigger("Shoot");
}
if(State == 1)
State = 2;
}
}
public void SpawnArrow()
{
// Arrow是与弩绑定的箭,gameObject是根据箭的位置以及弩的方向生成的新箭
Transform transform = Arrow.GetComponent<Transform>();
Vector3 position = transform.parent.TransformPoint(transform.localPosition);
Quaternion quaternion = transform.parent.rotation;
GameObject gameObject = Instantiate(NewArrow, position, quaternion);
Transform transform_ = gameObject.GetComponent<Transform>();
Rigidbody rigidbody = gameObject.GetComponent<Rigidbody>();
gameObject.tag = "WithTarget";
gameObject.AddComponent<ArrowController>().parent = gameObject;
rigidbody.AddForce(Force * 50 * transform_.forward);
}
}
值得一提的是,由于在弩弓播放动画时,我们是无法通过箭的Transform获取箭在视觉上所呈现出的位置的,究其原因,是播放动画时箭的Transform.position发生了变化(由动画导致的位移),此时Transform的控制权被移交给了Animator,此时是无法读取和修改关于它的实时信息的 ,因此最好的做法是不要将运动做成动画而是用代码控制。而在这里使用了一个十分取巧的方法:通过在射击动画播放时将动画自带的箭的renderer.enabled设为false来隐藏起来避免出镜,并且在动画的最后一帧上添加帧事件,在动画执行到最后一帧时自动调用SpawnArrow方法,从而实现真的通过弩发射了箭的效果。由于帧事件的相关内容十分的简单,这里就不提了。
注意:如果使用了帧事件,那么帧事件中调用的函数所在的脚本必须挂载在动画执行者上
另:由于在动画播放时箭的renderer.enabled会被更新为true,因此需要在Shoot的动画状态上添加动作,每播放一帧就将其renderer重新设为false,主要操作是选择在动画状态机的"Shoot",在监视窗口中选择"Add Behavior"(添加行为),添加新的控制脚本并将其中的OnStateUpdate方法改为以下代码:
C#
...
override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
animator.GetComponent<PullCrossBox>().renderer.enabled = false;
}
...
下面是需要挂载在箭的预制件上的控制脚本,主要作用是让箭在射出去10秒后消失:
C#
//ArrowController.cs
using UnityEngine;
public class ArrowController : MonoBehaviour
{
private readonly float existTime = 10;
public GameObject parent;
private float age = 0;
void Update()
{
age += Time.deltaTime;
if(age >= existTime)
Destroy(parent);
}
}
然后是判断是否位于射击位的控制代码,主要通过碰撞器实现,缺点很明显:可能存在一定的误判的可能性,跳起来时也会导致无法射击 (反正也不是FPS游戏,就不搞那么复杂了),不过好处是不论哪里有需要,都只要将此脚本挂载到对应场地的地面上就行:
C#
//ShootPlaceController.cs
using UnityEngine;
public class ShootPlaceController : MonoBehaviour
{
void OnCollisionEnter(Collision other) {
GameObject source = other.gameObject;
if(source.tag.Contains("Player")) {
source.GetComponentInChildren<PullCrossBox>().CanPull = true;
}
}
private void OnCollisionExit(Collision other) {
GameObject source = other.gameObject;
if(source.tag.Contains("Player")) {
source.GetComponentInChildren<PullCrossBox>().CanPull = false;
}
}
}
标靶设计
原本正常的设计逻辑是:由负责美术的设计好模型材质,打包好后应用于一个立方体,并且通过检测箭与标靶碰撞时的位置距离中心点的位置的距离来判断击中几环。但由于十分现实的原因,这里标靶只能通过多层Cylinder互相重叠的方式来实现,简单来说,就是通过下图的叠罗汉的方式实现:
不过需要注意的是,由于Cylinder默认的碰撞箱是胶囊型的(选中模型时会使用浅绿色的线条描绘碰撞箱的边缘),但我们并不希望箭在真正击中靶环之前就被一个看不见的碰撞箱给拦下来,因此需要使用Mesh Collider,并且勾选"Convex"选项告诉Unity这是一个凸模型,如下图:
并为标靶编写击中后的代码逻辑:
C#
//TargetAttribute.cs
using UnityEngine;
[CreateAssetMenu(fileName = "TargetAttribute", menuName = "ShootArrow/TargetAttribute", order = 0)]
public class TargetAttribute : ScriptableObject {
[Tooltip("击中得分")]
public int hitScore;
}
C#
//TargetController.cs
using UnityEngine;
public class TargetController : MonoBehaviour
{
public TargetAttribute attribute;
private FirstSceneController controller;
void OnCollisionEnter(Collision other) {
GameObject source = other.gameObject;
if(source.tag.Contains("WithTarget")) {
Rigidbody rigidbody = source.GetComponent<Rigidbody>();
rigidbody.useGravity = false;
rigidbody.velocity = Vector3.zero;
rigidbody.freezeRotation = true;
// 移除碰撞箱组件,防止二次碰撞
Destroy(source.GetComponent<BoxCollider>());
// 设置父亲为标靶,需要注意此时父亲的Scale等属性会影响到附着在上面的物体
other.transform.SetParent(gameObject.transform.parent);
// 击中得分
controller.AddScore(attribute.hitScore);
}
}
}
这里使用了ScriptableObject来为不同的靶环配置具体的得分,好处是修改和移植起来都十分的方便
游戏逻辑控制
虽迟但到,最核心的游戏计分以及其他一些控制,还有与用户交互的控制,给出核心代码:
C#
//FirstSceneController.cs
using System;
using UnityEngine;
public class FirstSceneController : MonoBehaviour
{
...
void Update()
{
if(Input.GetKeyDown(KeyCode.RightBracket))
{
timeSpeed = 2;
TimeController.instance.TimeSpeed = 2;
} else if(Input.GetKeyDown(KeyCode.LeftBracket))
{
timeSpeed = 1;
TimeController.instance.TimeSpeed = 1;
}
}
void OnGUI() {
GUI.Label(new Rect(100, 100, 100, 50), "总得分:" + totalScore.ToString());
Tuple<int, int, int> tuple = TimeController.instance.castTime;
GUI.Label(new Rect(100, 150, 150, 50), "当前时间:" + (tuple.Item1 == 1 ? "白天" : "晚上") + tuple.Item2.ToString() + ":" + tuple.Item3.ToString().PadLeft(2, '0'));
GUI.Label(new Rect(100, 200, 100, 50), "时间流速:" + timeSpeed.ToString() + "x");
}
public void AddScore(int score)
{
totalScore += score;
}
}
同样地,FirstSceneController需要在TargetController之前被加载。
场景设计
这部分主要看个人美术细胞了 (对于码农而言不太重要)
地形设计
通过Paint Texture给地形上色:
技巧:将地形统一为一个空游戏物体的子物体,通过禁止选中空物体就能避免总是选中地形
树木随机密铺:
昼夜与天空盒硬切换
游戏内时间计算以及天空盒的硬切换的核心代码:
C#
//TimeController.cs
using System;
using UnityEngine;
public class TimeController : MonoBehaviour
{
...
void Update()
{
curGameTime += Time.deltaTime * TimeSpeed;
UpdateScene();
if(curGameTime >= (dayGameTime + nightGameTime))
curGameTime -= dayGameTime + nightGameTime;
}
void UpdateScene()
{
if(curGameTime < dayGameTime)
GlobalLight.GetComponent<Transform>().eulerAngles = Vector3.right*180*curGameTime/dayGameTime;
else
GlobalLight.GetComponent<Transform>().eulerAngles = Vector3.right*180*(curGameTime - dayGameTime)/nightGameTime;
// 当curGameTime到达每个duration的上界时,需要更新Skybox,这里直接硬切换处理
if(curGameTime >= durations[curDuration])
{
curDuration = (curDuration+1)%8;
PlayerCamera.GetComponent<Skybox>().material = skyboxes[curDuration];
}
}
}
这里将游戏内的一天分为了白天与夜晚(具体长度由dayGameTime与nightGameTime给出),根据游戏内时间curGameTime调整光照GlobalLight的角度,并且在摄像机上添加Skybox组件来渲染天空盒,另外还可以通过代码:RenderSettings.skybox = anyMaterial
的方式修改全局天空盒的材质。
若要实现丝滑过渡,则需要Shader的相关知识了,这里已经放不下了,那么就先唠叨到这里咯~
- fin -