本节打算实现锁定敌人这个功能,这个功能可以细化为多个小功能,然后去逐一实现它。我们可以把它细化为
1.应获得锁定物体的一些信息,我们需要它的半高。
2.应可以连续锁定
3.锁定后,人物的走跑是始终面对敌人的,但跳滚是可以往各个方向的。
4.锁定后,敌人应始终在屏幕(相机)中央。更准确来说,应该是敌人的脚部要在屏幕中央(考虑到会有庞大体积的敌人,修脚)。
5.在锁定物的半高处显示锁定标记。
6.当人物远离锁定物体时自动取消锁定。
大概就这么多,接下来将过五关斩六将,逐一击破各个需求,最终实现一个基本的锁定功能。
我们先来考虑把这个锁定功能的主要实现放在哪。在锁定之后,要修改的有相机(CamerController.cs)和角色(ActorController.cs)。那我打算把锁定功能放在CamerController.cs处,然后宣告一个bool变量记录锁定的状态,然后把这个变量传给ActorController.cs,让其知道现在锁定的状态而决定是否做出相应的改变。相机自身是否做出改变由其内部的锁定功能提示。
然后再来考虑怎么锁定、获取和存放锁定的信息。锁定方面,我们可以利用虚拟碰撞器的碰撞检测来判定前方是否有能锁定的物体,这个虚拟碰撞器我们之前在实现下落(fall)的时候也有涉及到过,上次用的是
Physics.OverlapCapsule
做了个落地检测,这次我们可以弄个长方体的虚拟碰撞体,用的是Physics.OverlapBox
,我们可以先看看这个函数的相关信息,再开始我们的锁定。public static Collider[] OverlapBox(Vector3 center, Vector3 halfExtents, Quaternion orientation = Quaternion.identity, int layerMask = AllLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);
这个函数的参数有点多,但我们只需用到前4个参数,最后一个参数是碰撞触发器,这里用不上不再提及。
Parameters | Means |
---|---|
center | Center of the box. |
halfExtents | Half of the size of the box in each dimension. |
orientation | Rotation of the box. |
layerMask | A Layer mask that is used to selectively ignore colliders when casting a ray. |
第一个参数是长方体的中点;第二个参数是长方体的半长、半宽、半高;第三个参数是长方体的旋转量;第四个参数是只返回指定物体的碰撞结果。
Returns
Collider[] Colliders that overlap with the given box.
Description
Find all colliders touching or inside of the given box.
Creates an invisible box you define that tests collisions by outputting any colliders that come into contact with the box.
这个函数创建一个不可视的箱子,所有与这个箱子发生碰撞的物体都会被放在一个Collider数组然后返回给调用者。
那我们该如何合理的填入这些参数呢?关于box的大小,我打算创建一个10m长、1m宽、1高的一个长方体,且是以角色的当前坐标为起点,往前10m。,所以长方体的中心坐标是角色自身坐标(在地面其y是0),往上抬1m,再往前移5m。半长宽高不再赘述。box的旋转量我选择与角色的旋转量一致,这才能正确检测到的角色前进方向的物体。碰撞结果我们只需要Layer为Enemy的物体,到时候我会新增这一个Layer并创建带有这个Layer的物体以做测试。
另外我们还需要接收该函数返回的碰撞结果,所以就要把存放锁定的信息这方面也考虑上,我们需要存放被锁定的物体和这个物体的半高信息,思来想去,我觉得是新增一个私有类来存放比较好,利用构造函数传递被锁定物体的相关信息。
OK,现在我们就可以动手了,根据上面的结论,我们先在CameraController.cs创建一个私有类保存被锁定的物体和其半高:
private class LockTarget{
GameObject obj;
float halfWeight;
public LockTarget(GameObject _obj, float _halfWeight){
obj = _obj;
halfWeight = _halfWeight;
}
}
然后宣告一个该类的变量:
private LockTarget lockTarget;
是时候实现锁定功能了,我们定义一个函数在里面实现相关内容,在里面我们先设置为中心坐标:
public void LockUnlock(){
Vector3 tempPosition = model.transform.position;
Vector3 center = tempPosition + new Vector3 (0, 1.0f, 0) + model.transform.forward * 5.0f;
}
然后调用Physics.OverlapBox
函数,并填入相关参数,注意要宣告一个Collider数组接收它的返回:
public void LockUnlock(){
Vector3 tempPosition = model.transform.position;
Vector3 center = tempPosition + new Vector3 (0, 1.0f, 0) + model.transform.forward * 5.0f;
Collider[] col = Physics.OverlapBox (center, new Vector3 (0.5f, 0.5f, 5.0f), model.transform.rotation, LayerMask.GetMask ("Enemy"));
}
由于我们还有Enemy这个Layer,那我们先创建一个:
然后创建一个带有Enemy的Capsule:
现在来处理Collider数组,由于这个虚拟碰撞器是能穿透的,如果同时有多个物体与box发生碰撞,它都会一一记录在数组里,但我们只想要与角色最近的那一个,也就是数组的第一个,所以我们只把第一个给到lockTarget,那么问题来了,我们想要的不仅是碰撞的物体,还有物体的半高,物体可以通过
Collider.GameObject
来得到,那么怎么获取半高呢?其实Collider是有提供相关属性的,在Collider类里面包含另一个类Bounds的变量叫bounds,这个Bounds是一个描述边框的类,它能描述一个游戏物体的边界框,具体功能可以看官方的描述:
Description
Represents an axis aligned bounding box.
An axis-aligned bounding box, or AABB for short, is a box aligned with coordinate axes and fully enclosing some object. Because the box is never rotated with respect to the axes, it can be defined by just its center and extents, or alternatively by min and max points.
Bounds is used by
Collider.bounds
,Mesh.bounds
andRenderer.bounds
.
能表示一个轴对称(axis aligned)的边框,这个边框是能完全包围(fully enclosing)某个对象,且这个框从不相对于坐标轴旋转(跟坐标轴的方向永远一致),所以这个框可以只定义它的中心坐标和大小范围。我们要用到这个类的某一个属性就是extents,我们可以看看这个属性的含义,其他属性用不上这里就不再列举。
Properties | Means |
---|---|
extents | The extents of the Bounding Box. This is always half of the size of the Bounds. |
没错这就是我们要找的,我们能从这个属性得知物体的半长,半宽,半高,我们要的就是半高所以是extents底下的y属性。
public void LockUnlock(){
...
if (lockTarget!=null && col.Length!=0) {
foreach (var item in col) {
lockTarget = new LockTarget(item.gameObject, item.bounds.extents.y);
break;
}
}
}
现在我们可以存放被锁定物体的信息了,但是我们还不能触发锁定功能,应为没人来调用这个LockUnlock()函数,为此我们需要在IUserInput.cs里新增一个bool变量命名为lockOn,作为触发锁定(或解锁)功能的信号:
public bool lockOn;
然后我打算在手柄输入(JoystickInput.cs)那进行这个信号的处理,
至于键盘处就不再另说了,道理是一样的。在手柄里,一般锁定敌人的按钮都是放在右摇杆的按键处,按键位图是第11颗按钮:
那么我们就要来新增一个键位的相关信息,首先Unity里面的Input设置:
然后是相关代码设置,MyButton这些在上节实现DoubleTrigger & Long Press已经讲的很详细了,不再赘述。
...
public string keyBut11 = "btn11";
...
private MyButton Button11 = new MyButton();
void Update () {
...
Button11.Tick (Input.GetButton (keyBut11));
...
//锁定角色
lockOn = Button11.OnPressed;
}
然后再在ActorController.cs里对这个信号做反馈,我想通过这个信号来决定是否调用LockUnlock()
函数,但前提是我得能调用到这个函数才行,这个函数在CamerController.cs里,所以我必须获得这个组件。这个组件在PlayHandle的子物体CameraHandle底下,那么能不能通过找到子物体去获得它的组件呢?Unity提供了一个transform.Find()
函数来查询当前物体的某个子物体,可以看一下它的Signature:
public Transform Find(string n)
Parameters | Means |
---|---|
n | Name of child to be found. |
Returns
Transform The returned child transform or null if no child is found.
Description
Finds a child by
n
and returns it.If no child with
n
can be found, null is returned. Ifn
contains a '/' character it will access the Transform in the hierarchy like a path name.Note: Find does not perform a recursive descend down a Transform hierarchy.
这个函数返回指定子物体的transform,如果没有找到就返回null。而且能访问路径(用/符号)一样去访问层级结构(hierarchy)中的转换。我们可以先看看目前的层级结构如何:
能看到CameraController.cs在CameraPos下,而ActorController.cs我也重申一遍在PlayerHandle下,那么我们需要用
transform.Find()
函数查询CameraHandle/CameraPos这个路径的子物体,并在这个子物体使用GetComponent<>()
这个方法获得组件:
//在ActorController.cs文件里
[SerializeField]
private CamerController camcon;
void Awake(){
...
camcon = transform.Find ("CameraHandle/CameraPos").GetComponent<CamerController> ();
}
我把camcon这个私有变量放在[SerializeField]
底下是为了观察camcon能否真的获取到它想要的组件,我们可以来看看:
在我点击Play Mode后可以看到Camcon变量正确地获取到了CameraController组件。
现在就可以通过camcon来调用
LockUnlock()
函数了:
void Update () {
...
if (pi.lockOn) {
camcon.LockUnlock ();
}
...
}
现在我们就可以来测试一下LockUnlokc()
函数能否正确锁定到该锁定的物体,我通过在LockTarget
的构造函数里输出被锁物体的Layer名字进行测试:
public LockTarget(GameObject _obj, float _halfWeight){
obj = _obj;
halfWeight = _halfWeight;
print(LayerMask.LayerToName(obj.layer));
}
能看到在我对着这个Layer为Enemy的Capluse键入锁定键(btn11)后LockTarget的构造函数如我所愿地输出了被锁物体的Layer,意味着构造函数被正常调用且获得想要的信息。
至此我们的第一个小功能实现了,接下里该考虑第二个需求,连续锁定,意思就是在我锁定第一个敌人后,我想锁定傍边的第二个敌人是应该是屏幕对准它再按一次锁定键即可,且如果是对同一个敌人再按一次锁定键就应该取消锁定。而我们现在的锁定逻辑是:
if (col.Length != 0) {
foreach (var item in col) {
lockTarget = new LockTarget (item.gameObject, item.bounds.extents.y);
break;
}
} else {
lockTarget = null;
无论虚拟碰撞器碰到的是哪些敌人它都把最近的那一个赋予lockTarfet,虽然它能够切换锁定的敌人,但是就没有再锁定取消的功能,只有锁定后对着空气按锁定键才能解锁。现在的逻辑稍微有些欠缺,所以要修改我们的逻辑以满足我们的需求。我们应该在检测虚拟碰撞器的反馈if (col.Length != 0)
后在增加一个判断,判断其反馈的物体是不是与被锁定的物体相同,当然还要判断现在有没有被锁定的物体,如果是就取消锁定(把lockTarget设为null),如果不是就把碰撞物体赋予lockTarget。
if (col.Length != 0) {
if (lockTarget==null || lockTarget.obj != col [0].gameObject) {
foreach (var item in col) {
lockTarget = new LockTarget (item.gameObject, item.bounds.extents.y);
break;
}
} else {
lockTarget = null;
}
} else {
lockTarget = null;
}
现在可以测试一下这个逻辑是否行得通,我在LockTarget的构造函数上输出被锁物体的名字以分辨我现在锁的是哪个物体,然后新增一个析构函数来表示解锁,这样方便我进行测试:
public LockTarget(GameObject _obj, float _halfWeight){
obj = _obj;
halfWeight = _halfWeight;
print(obj.name);
}
~LockTarget(){
print("Unlock");
}
然后我在新增一个Capsule敌人:
我的操作是先锁定Capsule(1),解锁Capsule(1),这是能看到它解锁的信息(析构函数被第一次调用),再锁定Capsule(1),没解锁的情况下去锁定Capsule,能看到析构函数仍被调用了(第二次调用),最后解锁Capsule(第三次调用析构函数)。能看到最后3条Unlock信息,两条Capsule(1)信息,一条Capsule信息,从信息数量上和信息的出现顺序上都是符合我的操作的,说明这个逻辑没什么大问题。
连续锁定也算是基本实现了,现在来解决人物的朝向问题,在锁定敌人后,人物应该始终面向敌人,这个功能的实现不仅要涉及到代码的修改,还要动画的修改。我们先来看看代码的修改:
模型的方向不应该变,那么胶囊的方向也不应该变,由于在实现第三人称视角的时候,CamerController.cs就包含了一个PlayerHandle类型变量,用来控制视角的水平旋转
private GameObject playerHandle;
void Awake () {
...
playerHandle = cameraHandle.transform.parent.gameObject; //负责水平旋转
...
}
那么我们可以利用这个变量来控制胶囊体的方向始终面对被锁定敌人的方向,然后模型的方向跟随胶囊方向,这样就解决了部分问题了。我们可以通过敌人的坐标减去胶囊的坐标就能得出胶囊朝向的方向:
在没有锁定敌人是,相机的旋转照常运作,在锁定敌人后,相机就不再旋转,而是始终看向(LookAt()
)敌人:
if (lockTarget == null) {
Vector3 tempModelEuler = model.transform.eulerAngles;
playerHandle.transform.Rotate (Vector3.up, pi.CRight * horizontalSpeed * Time.deltaTime);
//cameraHandle.transform.Rotate (Vector3.right, pi.CUp * verticalSpeed * Time.deltaTime);
tempEuler = Mathf.Clamp (tempEuler, -20, 30);
tempEuler += pi.CUp * -verticalSpeed * Time.deltaTime;
cameraHandle.transform.localEulerAngles = new Vector3 (tempEuler, 0, 0);
model.transform.eulerAngles = tempModelEuler;
}else {
Vector3 tempForwand = lockTarget.obj.transform.position - playerHandle.transform.position;
tempForwand.y = 0;
playerHandle.transform.forward = tempForwand;
cameraHandle.transform.LookAt (lockTarget.obj.transform);
}
现在在我锁定之后,无论我走多远,相机都始终看向敌人:
现在是胶囊朝向了敌人,相机也朝向了敌人,只差模型还没朝向敌人。不过于模型而言可不是加几行代码这么简单了,因为在动画机还要演出绕着敌人走的动画,我们现在的动画机是无法满足的这个需求的。因为我们只有forward值,即只有往前走的参数,所以只能往前走。
于是乎,我们应该重新调整我们的动画机,主要是调整ground这个blend tree里面的内容,这是它现在的概貌:
我们要实现左右横走的话,现在这个混合树的模式(1D)是无法满足的,需要的是2D Freeform Directional:
我们可以看看官网上关于这个词组的解释:
2D Freeform Directional: This blend type is also used when your motions represent different directions, however you can have multiple motions in the same direction, for example “walk forward” and “run forward”. In the Freeform Directional type the set of motions should always include a single motion at position (0, 0), such as “idle”.
当你想用不同动作代表不同的方向时,就是可以用这种混合形式,而且你能够在同一方向上使用多个(multiple)动作,它举的例子就是往前走和往前跑。另需注意的是,在这个形式下需要给一个简单的动作于原点位置,例如闲置(idle)。
在选择了这个混合模式后,它需要的参数就变为了两个,一个代表X轴,一个代表Y轴,Y轴可以用原来的forward值,但X轴我们就要新建一个给它了。我们新建一个float参数命名为right,作为第二个参数:
现在我们可以在这个二维空间里的某个位置放上你想播放的动画,用现已有的动画作个示范:
我们还要为它加上往左走、跑,往右走、跑,往后退的动作,最终是:
我们可以看看其内部展示如何:
现在能看到现在模型的方向无论往哪个方向走其正面朝向都是不变的。不过可能这些动画混合的效果不是特别好,由其是往左上方向时脚步有点凌乱,素材有限,我也无可奈何。
现在要做的就是把新来的参数放进代码中。在没有锁定敌人的时候,动画的播放依旧只设置forward的值,那么就如以前一样,用Dmag(纯正整数)控制forward值,这样就只会播放Y的正半轴的动画。在锁定敌人后,因为Dvec是个向量,是有方向的,更有正负值的,就可以用Dvec来设置forward值和right值。 如果忘了这两个变量是怎么来的可以回忆一下:
if (!InputEnabled) {
Dmag = 0;
}else
Dmag = Mathf.Sqrt (Dup2 * Dup2 + Dright2 * Dright2);
Dvec = Dup2 * transform.forward + Dright2 * transform.right;
设置动画参数是在ActorController.cs里完成的,所以它必须知道现在有没有在锁定敌人以做进一步的处理。那么我们就要在CamerController.cs里宣告一个bool变量并通过在ActorController.cs里的camcon对象访问这个变量以获得现在的锁定状态。而在CamerController.cs里在获得锁定物体后设置这个bool变量为true,解锁就设为false:
//在CamerController.cs里
public bool lockState;
...
public void LockUnlock(){
...
if (col.Length != 0) {
if (lockTarget==null || lockTarget.obj != col [0].gameObject) {
foreach (var item in col) {
lockTarget = new LockTarget (item.gameObject, item.bounds.extents.y);
lockState = true;
break;
}
} else {
lockTarget = null;
lockState = false;
}
} else {
lockTarget = null;
lockState = false;
}
}
ActorController.cs访问lockState,如果不是锁定状态(lockState为false)那么一切如常,之前的是怎么设forward值就怎么设;如果是锁定状态,那么用Dvec的z分量设置forward值,用Dvec的x分量来设置right值,不过在此之前要将Dvec坐标转为局部坐标:
if (!camcon.lockState) {
anim.SetFloat("forward", Mathf.Lerp(anim.GetFloat("forward"),pi.Dmag*(pi.run?2.0f:1.0f),0.1f));
anim.SetFloat ("right", 0);
if (pi.Dmag>0.1f) {
model.transform.forward = Vector3.Slerp (model.transform.forward, pi.Dvec, 0.1f);;
}
if (lockVelocity==false) {
movingVec = pi.Dmag * model.transform.forward * (pi.run ? runSpeed : 1.0f); //速度
}
}else{
model.transform.forward = transform.forward;
movingVec = pi.Dvec * (pi.run ? runSpeed : 1.0f);
Vector3 localDVec = transform.InverseTransformVector(pi.Dvec);
anim.SetFloat ("forward", localDVec.z*(pi.run?2.0f:1.0f));
anim.SetFloat ("right", localDVec.x*(pi.run?2.0f:1.0f));
}
现在能在锁定状态下绕着敌人走了:
但是还有一个问题就是现在的跳跃和翻滚的演出方向都是向前的,甚至翻滚的情况是无论角色在往哪个方向走它都是往前翻滚,原因是角色的朝向在锁定状态时被锁死了,且翻滚给的冲量的方向是角色朝向。
void OnRollState()
{
thrustVec = model.transform.forward * anim.GetFloat ("rollVelocity");
}
解决方法是我们应该在跳跃和翻滚状态时解除角色朝向的锁死,让其能够面向当前移动的方向进行跳跃和翻滚。为此我们宣告一个bool变量命名为trackDirection,这bool值决定当前角色的朝向是面向敌人还是跟随移动的方向:
private bool trackDirection;
void Update () {
if (!camcon.lockState) {
anim.SetFloat("forward", Mathf.Lerp(anim.GetFloat("forward"),pi.Dmag*(pi.run?2.0f:1.0f),0.1f));
anim.SetFloat ("right", 0);
if (pi.Dmag>0.1f) {
model.transform.forward = Vector3.Slerp (model.transform.forward, pi.Dvec, 0.1f);;
}
if (lockVelocity==false) {
movingVec = pi.Dmag * model.transform.forward * (pi.run ? runSpeed : 1.0f); //速度
}
}else{
if (lockVelocity==false) {
movingVec = pi.Dvec * (pi.run ? runSpeed : 1.0f); //速度
}
if (trackDirection == false) {
model.transform.forward = transform.forward;
} else {
model.transform.forward = movingVec.normalized;
}
Vector3 localDVec = transform.InverseTransformVector(pi.Dvec);
anim.SetFloat ("forward", localDVec.z*(pi.run?2.0f:1.0f));
anim.SetFloat ("right", localDVec.x*(pi.run?2.0f:1.0f));
...
}
现在考虑这个布尔值的切换时机,它应该在进入翻滚和跳跃状态时设为true,允许其朝移动方向作翻滚和跳跃。而在这两个状态之外就不能改变朝向(跟胶囊朝向一致)。
void OnGround(){ //当进入地面状态时
pi.InputEnabled = true;
lockVelocity = false;
canAttack = true;
col.material = frictionOne;
trackDirection = false;
}
void OnRollEnter()
{
//thrustVec = new Vector3 (0, rollVelocity, 0);
pi.InputEnabled = false;
lockVelocity = false; //当跳跃时锁死速度,不让速度归0
trackDirection = true;
}
void OnJumpEnter(){
pi.InputEnabled = false;
lockVelocity = true; //当跳跃时锁死速度,不让速度归0
canAttack = false;
trackDirection = true;
thrustVec=new Vector3(0,jumpVelocity,0);
}
现在应该就可以在锁定状态时随移动方向跳跃和翻滚了:
现在来解决最后一个视角的问题,就是要看到敌人的脚部,其实就是敌人的脚部始终在屏幕的中央,为什么要这样做呢?因为面对一些体积巨大的敌人,看向敌人脚部会把整个视角拉高,这样容易把整个敌人容纳在屏幕内。
在之前我们就已经使用了
LookAt()
函数锁定相机的视角
camera.transform.LookAt (lockTarget.obj.transform);
那么我只要把敌人的transform坐标系 统一放在底部就可以了。
首先先在一个敌人的游戏物体上新建一个empty:
然后把它拉出Capsule的子层,与Capsule同层:
再改变这个空物体的Y坐标,设为0,记住此时的坐标模式是Pivot:
这样这个transform就会来到底部,最后让Capsule做其子层:
那么现在看敌人时其实就看这个GameObject的transform位置了。在此之前我们可以在屏幕中央设置一个标记看看是否看向底部,这个标记之后也是用来做锁定标记的。我们新建一个UI→Image,然后把它坐标轴设在屏幕中央,且坐标设为(0,0),至于图片样式我选的是Unity自带的Knob:
因为我没做任何处理,所以这个标记会一值在屏幕中央显示:
能看到即使标记在Capsule顶部,在我锁定Capsule之后,标记立刻跑到底部去了,说明是可行的。
现在要对这个标记进行处理了,它的基本功能是在我锁定敌人之后在出现,解锁敌人后消失,且标记始终在敌人的半高处。那么首先我要在代码中获得这个image对象,才能进行下一步处理。在CameraController.cs里使用UnityEngine的UI库:
using UnityEngine.UI;
然后宣告一个Image对象,把新建的image标记灌给这个对象:
public Image lockDot;
如果锁定了敌人了我们就可以使用
Image.enabled
来控制标记的出现
if (lockTarget == null) {
lockDot.enabled = false;
...
}else {
lockDot.enabled = true;
...
}
现在要设置的就是它出现的位置,UI只会出现屏幕上,所以它对应的是屏幕上的坐标,而游戏里的物体对应的是游戏的世界坐标,两者的坐标系不一样,要想UI显示在某个特定的世界坐标点上,就要使用一个转换的方法:Camera.main.WorldToScreenPoint
,它能把某个特定的世界坐标作为它所在的屏幕坐标(在屏幕上出现的位置(二维))。
lockDot.transform.position = Camera.main.WorldToScreenPoint (lockTarget.obj.transform.position + new Vector3 (0, lockTarget.halfWeight, 0));
有了这句之后,当锁定敌人锁定标记就会始终在锁定物体的半高处,我们可以来看看:
可以看到,无论角色怎么走,标记都始终在敌人半高处。
还剩一个需求,坚持住。现在在我们锁定敌人之后,无论跑多远,都不会自动解锁,显然远离解锁是一个必须考虑到且很基本的一个要求。这个其实并不难,用到的一个API是Vector3.Distance
,它能够计算并返回两个坐标之间的距离,那我们可以把被锁定物体的坐标和自身的坐标喂给它,只要距离超过某个阈值就解锁:
if (Vector3.Distance(lockTarget.obj.transform.position, model.transform.position) >10.0f) {
lockTarget = null;
lockDot.enabled = false;
lockState = false;
}
现在我们就可以在远离时自动解锁敌人了:
至此,整个锁定功能就基本实现了。
`