欢迎光临散文网 会员登陆 & 注册

从零开始用Unity做一个海战游戏(下)

2018-11-04 20:24 作者:皮皮关做游戏  | 我要投稿

作者:沈琰


本篇难度:★★★☆☆

前言

这个小工程终于也到最后一期了。为表示庆祝,换个风格的战舰作为题图。

还是先来个上期的传送门:从零开始用Unity做一个海战游戏(中)

如上期所讲,这期的内容主要是实现武器的攻击逻辑,让船之间能真正的战斗起来。



投射物触发检测

自己做过类似游戏的人都知道,检测碰撞信息通常都是物理系统中的刚体(Rigbody与碰撞器(Collider)搭配使用实现。

Unity中想要检测到两者间的碰撞信息需要均挂载碰撞器,然后至少得有一边挂载刚体。之前在实现船的移动逻辑时已经在船上加载过刚体了,那么现在子弹上就只需要碰撞器,所有的被攻击判定放在船上实现。

想到这就出现了两个问题:

1.处理同阵营间的攻击逻辑

假设现在是一个主角对一群的敌人。敌人如果用的是导弹可能还好,若是鱼雷或者火炮,在敌人数量众多的情况下估计主角还没被打死,就会有一堆敌人被自己人打成筛子。

当然,这个问题也能解决,复杂点的就是在AI上加一个躲避队友射击方向的移动逻辑。不过这想法太过南辕北辙,用脚指头想想就觉得很麻烦,效果还不见得好,因此Pass。

简单一点的方法是做碰撞检测时先过滤一下层级,把发射出去的子弹打上自己的“标记”,当检测到子弹碰撞信息时发现是自己人打出来的子弹就滤过去。换而言之就是把队友误伤关掉了,让子弹直接穿过去,毕竟只是一个小项目,不用过多考虑真实性的问题。

2.子弹击中船体以外其他碰撞器的处理

现在的命中逻辑有这么一个问题,如果武器击中了其他碰撞器怎么办?

打个比方,导弹的转向角速度不够,没有命中敌船而是一头钻进了海里,如果这时候导弹又一头从水里钻出来打中目标....,就算小工程不太讲求真实性,可这样看起来也太怪异了。

按道理导弹钻进水里应该直接就销毁了,所以要额外在水面上挂个脚本去处理碰撞信息,不过扩展起来就很麻烦。

现在场景里是只有一个水面,以后还可能会有陆地、岛屿、陆地上的建筑等,总不能每次添加新的场景物体就再写个脚本。

思来想去还是得把总的碰撞逻辑放在子弹上比较靠谱,所以检测函数写在子弹的基类上,然后在每一个场景物体中添加刚体,再根据检测到的层级去分别处理。

——————————————————————————————————————


以上是常规方法,实际情况是在项目中使用刚体检测物理碰撞非常消耗性能,所以对于类似子弹这样的投射物一般自己写代码或使用射线检测这样性能消耗较小的方式。

在初学Unity时我曾用在子弹上挂刚体的办法实现了一把枪的功能,当场景中有多把枪同时发射时编辑器就直接卡死了...,所以得用个折中的办法去处理这个问题。


using System.Collections;

using System.Collections.Generic;

using System;

using UnityEngine;

 

 

 

public class Projectile : MonoBehaviour

{

 

 

    RaycastHit[] hit;

 

    public ParticleSystem hitTarget;

    public ParticleSystem hitWater;

    public ParticleSystem hitGround;

    public ParticleSystem disappear;

 

    protected GameObject shooter;

 

  

    [SerializeField]

    protected LayerMask hitable;

    protected LayerMask templayer;

 

    [SerializeField]

    protected LayerMask player;

    [SerializeField]

    protected LayerMask enemy;

    [SerializeField]

    protected LayerMask ground;

    [SerializeField]

    protected LayerMask water;

 

 

 

 

    protected virtual void HitUpdate()

    {

        if (Physics.SphereCastNonAlloc(transform.position, radius, direction.normalized, hit, distance, hitable) > 0)

        { 

            templayer = hit[0].collider.gameObject.layer;

            distance = hit[0].distance; 

            if (((1 << templayer) & (player | enemy)) != 0)

            {    

                if(hitTarget)

                PlayParticleAtPoint(Instantiate(hitTarget), hit[0].point, hit[0].normal);

                hit[0].collider.GetComponentInParent<Ship>().BeHit(damage);

            }

            else if (((1 << templayer) & ground) != 0)

            {

 

            }

            else if (((1 << templayer) & water) != 0)

            {         

                if (hitWater)

                    PlayParticleAtSurface(Instantiate(hitWater), hit[0].point);     

            }

            Destroy(gameObject);

        }

    }

 

 

    //初始化投射物

    public virtual void Init(Vector3 _position, Vector3 _direction, float _speed, float _lifetime, int _damage, GameObject shooter)

    {

        transform.position = _position;

        direction = _direction;

        transform.forward = direction;

        speed = _speed;

        lifeTime = _lifetime;

        damage = _damage;

        this.shooter = shooter;

    

        //过滤掉自身的层级

        hitable = hitable | player | enemy;

        hitable = hitable &(~(1 << shooter.layer));

    }

    protected virtual void PlayParticleAtPoint(ParticleSystem pc, Vector3 point, Vector3 direction)

    {

        pc.transform.position = point;

        pc.transform.rotation = Quaternion.LookRotation(direction);

        pc.Play();

    }

 

    protected virtual void PlayParticleAtSurface(ParticleSystem pc,Vector3 point)

    {

        pc.transform.position = new Vector3(point.x, 0, point.z);

        pc.transform.rotation = Quaternion.identity;

        pc.Play();

    }

 

    private void OnDrawGizmosSelected()

    {

        Gizmos.color = Color.red;

        Gizmos.DrawSphere(transform.position, radius);

    }

}

 

以上是检测碰撞部分的代码,在上一期的文章里已经计算出了投射物的运行轨迹,可以很方便的获取投射物当前的方向和移动距离。使用球形投射检测(Physics.SphereCastNonAlloc)的方式根据投射物的半径在每一个固定帧去检测移动方向上有没有碰撞体。

就像是打出一条柱状射线一样,没有使用射线检测主要是考虑到可能会有半径较大的投射物擦着碰撞体过去却没有检测到碰撞的情况,也比较好调整各种不同类型的子弹的触发判定范围。

当然,球形投射要比射线检测消耗更多的性能,参考这篇博客:Unity中各类物理投射性能横向比较。若实际运行时有性能方面的问题再改回射线检测也比较方便。

再根据投射物和击中碰撞体的类型播放不同的粒子效果,同时额外写一个脚本记录船的生命值,用协程做一个简单的沉船动画,一个简单的海上战斗原型就算完成了:

海面效果是从另外的工程里拖来的材质球,对着色器不太熟悉,这里也就不细说了。


AI目标的提前量与炮弹抛物线轨迹计算

现的AI还是显得很“笨拙”,主要表现在火炮的命中上。只要玩家在移动,AI是永远打不着玩家的:


所以这里要根据玩家的移动方向和速度计算一下提前量:

这时炮弹速度与船的速度都是已知的,实际距离也可以用坐标间的距离求得。虽然时间未知,但当击中目标时时间相同的,故而炮塔当前朝向与船的朝向的夹角α的正切值实际是等于炮弹速度与船速的比,这样通过三角函数换算可以得到一个一元二次方程,求解即可以得到命中的时间T。

本来到一步问题已经解决了,已经准备开始做一些收尾的工作了。当我准备捏一个大一些的船当BOSS时突然想到了一个问题:

其实一开始就想过让炮弹以抛物线轨迹运动,当时嫌麻烦没写,但现在的问题是如果炮口位置是高于目标船的高度那就永远都打不到了。

当然能用一些取巧的方法来解决,比如炮弹上再额外加一个向下的射线检测,把摄像机调成俯视角后应该是看不出来的。

不过看标题你也知道了,为了表现力更好一些还是选择了更麻烦的计算抛物线弹道,逛了一圈等于又绕回来了。不过当抛物线与提前量计算结合起来,这个问题陡然就变得棘手起来。


——————————————————————————————————————


咱们把问题分解一步步来,首先是将炮弹的运行轨迹改成抛物线。因为炮弹上是没有刚体的,这部分的运动逻辑要在代码里自己来模拟,在子类里来修改一下移动逻辑:

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

 

public class Bullet : Projectile

{

 

    float g = 10f;

 

    float time = 0;

 

    Vector3 initDiretcion;

 

    protected override void MoveLogicUpdate()

    {

        Vector3 move;

       

        Vector3 gravDir = Vector3.down * g *time; 

        move = initDiretcion * speed  + gravDir;

        direction = move.normalized;

 

        distance = move.magnitude*Time.fixedDeltaTime; 

        transform.position += move * Time.fixedDeltaTime;

        time += Time.fixedDeltaTime;

    }

    public override void Init(Vector3 _position, Vector3 _direction, float _speed, float _lifetime, int _damage, GameObject shooter)

    {

        base.Init(_position, _direction, _speed, _lifetime, _damage, shooter);

 

        initDiretcion = _direction;

    }  

}

 

把抛物线每个时间点的速度分解为水平方向和垂直方向来看,水平方向是匀速直线运动,而垂直方向是匀加速直线运动。

上面这段代码的意思就是分开计算水平和垂直速度。

假设初始射击角度平行于Z轴,那么水平速度就等于初始速度不变,再用时间算和重力加速度算出垂直方向是瞬时速度,在每个时间点上把两个速度分量相加,得到的就是当前的实际速度。

先用和子弹同样的逻辑在场景内把这条抛物线画出来,验证一下计算是否准确。、

void SimulationDropPos_visualization(Vector3 direction)

    {

        float time = 0;

 

        Ray ray=new Ray();

        RaycastHit hit;

        Vector3 move=Vector3.zero;

 

        Vector3 curPos = muzzle.position;

 

        while (!Physics.Raycast(ray,out hit,move.magnitude,1<<LayerMask.NameToLayer("Sea"))&&curPos.y>0)

        {

            Vector3 gravDir = Vector3.down * G * time;

 

            move = (direction * speed + gravDir)*Time.fixedDeltaTime;

 

            ray = new Ray(curPos, move);

            Debug.DrawRay(curPos, move, Color.red);

            curPos += move;

            time += Time.fixedDeltaTime;

        }

        if(hit.transform)

        {

            curPos = hit.point;

        }

      

        Debug.DrawRay(curPos, Vector3.up * 10, Color.black);

    }

 

可以看到计算应该是没有问题的,下一步就是根据这条抛物线来计算目标的提前量。

但加了一个维度后,解决这个问题就比之前要困难很多了。简单来说就是没办法只通过一次计算来得到正确值了。

在不考虑什么空气阻力之类的问题的情况下水平速度等于炮弹速度与出膛角度余弦值的乘积,当一开始以目标现在的位置计算时间时,得到的炮弹命中的时间实际是以之前的角度换算的水平速度计算出来的,而以这个时间参数计算出的目标提前量就不准确。因为实际的时间是要大于计算中的参数时间。

办法就是用这个有误差的时间计算出的预估提前量作为当前目标再次计算,如此反复多次直到上一次计算出的时间与当前计算出的误差小于一个给定的精度。

从得到方法到写进代码里还得经过一些数学推导计算,以本人的数学功底也就不在这献丑了,对此处有兴趣的同学可以参考这里:

游戏的物理和数学:Unity中的弹道和移动目标提前量计算
http://www.ceeger.com/forum/read.php?tid=3919&fid=2&page=1

计算部分的代码:


    //accuracy(精度)给得越高,递归计算的次数也会越多

    public Vector3 CalculateLeadPos(Vector3 tarPos,float tarSpeed,Vector3 tarTowards,float accuracy,Vector3 sim_Point,float diff)

    {

        if (sim_Point==Vector3.zero)

        {

            return Vector3.zero;

        }

        Vector3 tarDir = (Vector_Y2Zero(sim_Point) - Vector_Y2Zero(transform.parent.position)).normalized;

        Quaternion tarRotation = Quaternion.FromToRotation(tarDir, Vector3.forward);

        Vector3 LocalHitPos = tarRotation * (sim_Point- muzzle.position);

 

        float V = speed;

        float X = LocalHitPos.z;

        float Y = -LocalHitPos.y+d_offset;

        Vector2 TT = SimulationProjectile(X, Y, V, G);

       

        if(TT==Vector2.zero)

        {

            return Vector3.zero;

        }

        Vector3 newSim_point = Sim_DropPos(tarSpeed, tarPos, tarTowards, TT.y);

        float curDiff = (newSim_point - sim_Point).magnitude;

        if(curDiff>diff)

        {

            Debug.Log("Error:Out Of Range Or Other");

            return Vector3.zero;

        }

        if (curDiff<accuracy)

        {

            Debug.DrawRay(newSim_point, Vector3.up * 10, Color.yellow);

            AngleOfPitch = TT.x * Mathf.Rad2Deg;

            return  newSim_point;

        }

        return CalculateLeadPos(tarPos, tarSpeed, tarTowards, accuracy, newSim_point, curDiff);

    }

 

    Vector2 SimulationProjectile(float X, float Y, float V, float G)

    {

        if (G == 0)

        {

            float THETA = Mathf.Atan(Y / X);

            float T = (Y / Mathf.Sin(THETA)) / V;

            return (new Vector2(THETA, T));

        }

        else

        {

            float DELTA = Mathf.Pow(V, 4) - G * (G * X * X - 2 * Y * V * V);

            if (DELTA < 0)

            {

                return Vector2.zero;

            }

            float rad1 = Mathf.Atan(((V * V) + Mathf.Sqrt(DELTA)) / (G * X));

            float rad2 = Mathf.Atan(((V * V) - Mathf.Sqrt(DELTA)) / (G * X));

        

            float rad = Mathf.Min(rad1, rad2);

            float T = X / (V * Mathf.Cos(rad));

            return new Vector2(rad, T);

        }

    }

 

    Vector3 Sim_DropPos(float speed, Vector3 curPos, Vector3 tarTowards, float time)

    {

        Vector3 sim_pos = Vector_Y2Zero(curPos) + Vector_Y2Zero(tarTowards) * (speed * time);

        return sim_pos;

    }

 

对于炮管来说就不用关心在XZ坐标系的瞄准方向问题了,只需要得到在YZ坐标系的俯仰角即可,再把得到的模拟坐标参数传回给炮塔,让炮塔转向这个位置。

然后就是老办法,用可视化的方法把模拟坐标位置显示在场景里,验证计算结果:

目测似乎问题不大,但还是得实际运行时检验一下。可以把敌人复制几个摆在不同的方位上测试一下整体的命中率:

......感觉难度一下又变得太大了,只要玩家的速度变化不太大,AI的命中率就很高。不过这个没关系,可以再加个随机参数去调整偏移值,就结果来说基本之前的目的是达到了。



游戏中的粒子效果

这部分本来是想详细说一下,但是写到这又觉得没什么可说的。只要熟悉粒子系统的各个模块功能,做点类似的简单效果就没什么难度,具体怎么做反倒是因人而异了。

但文章里又没法详细的去介绍这些模块的使用,因此向大家推荐一个关于粒子系统的参考视频:【特效制作学习】如何制作塞尔达传说-荒野之息神庙特效【完结】,有兴趣的同学可以自行研究一下。


关于这里就只说一个遇到的问题:

在实现鱼雷水下爆炸的粒子效果时,想让溅起的水花回落到海上有一个波纹效果。于是用到了粒子系统中的Triggers模块。

但是发现模块中的Colliders似乎无法在预制体中静态加载,因此这里是在实例化时在脚本中赋值。

protected override void PlayParticleAtPoint(ParticleSystem pc, Vector3 point, Vector3 direction)

    {

     

        if(point!=Vector3.zero)

        {

            pc.transform.position = new Vector3(point.x, 0, point.z);

        }

        else

        {

            pc.transform.position = transform.position;

        }

        pc.transform.rotation = Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z));

        //设置水花击中海面的trigger

        pc.transform.GetChild(0).GetComponent<ParticleSystem>().trigger.SetCollider(0, GameObject.Find("Sea").transform);  

        pc.Play();

    }

 

这里并没有让生成的子粒子效果继承任何属性,理论上波纹的粒子效果应该是初始角度,但实际击中海面的角度有一些偏差:

目前暂时还未找到问题出在哪,不过俯视角中并不太影响,暂时先这么凑合了,如果哪位大佬知道希望能在评论中赐教。


结束

其实游戏以完成度来说还远不够,不过基本的结构已经搭建起来了。剩下的部分包括装备系统的扩展、场景搭建、UI等等这些比较耗时且与基本结构没什么太大关系。

所以这个小游戏以后还会抽空继续做下去,只不过文章部分就到此为止了,有兴趣的同学也可以以此为基础发挥想象力自行修改,感谢观看到此。

本期工程地址:https://github.com/tank1018702/unity-004


最后想系统学习游戏开发的童鞋,欢迎访问 http://levelpp.com/     

游戏开发搅基QQ群:869551769   

微信公众号:皮皮关

从零开始用Unity做一个海战游戏(下)的评论 (共 条)

分享到微博请遵守国家法律