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

作者:沈琰
本篇难度:★★☆☆☆
前言
好长时间没更新了,但是请放心,我并没有细软跑。

先看看这段时间都加了些啥新东西:

给还没看过上期的同学做个简短的上期回顾:从零开始用Unity做一个海战游戏(上)
上期里我们亲手捏出了一条小船并装上了第一种武器火炮,然后实现了炮塔旋转的逻辑。这期主要的内容将是船的移动逻辑和武器种类的扩展。
那么不多说废话了,我们继续。
船的移动
本来一开始我觉得移动逻辑挺容易写的,但是尝试了一下发现并不是很简单。

现在只有一条船,但是当有多条船一起移动时会涉及到碰撞问题,所以最初的想法是用物理系统来实现这个移动逻辑。
实际上用Rigidbody.AddForce()的方法来实现的话,一是用物理材质模拟阻力之类的参数不太好调整,二是受力点的位置也不太好选取,容易出一些奇奇怪怪的BUG。所以就直接通过修改Rigidbody.velocity的值来实现移动。同时为避免在碰撞的时候船的朝向出问题,锁了Y轴的移动和X,Z轴的旋转。
那么现在需求就转化为获取一个Vector3类型的速度值了,然后再来思考一下具体的移动方式。
实际中的船只移动逻辑极为复杂,我们当然没有必要去做到那么拟真,就以类似游戏中的船移动模式作为参考:
船的移动方式跟地面上的载具不太一样。首先船一般都是有较大的惯性的,表现在实际的移动中就是船的加速和减速都比较困难,需要较长时间船才能达到最大速度或者完全停止。
这个还算比较容易实现,简单来说就是先得到船的移动方向的向量后,把加速度去做差值处理,每一帧去更新这个值。
而船的转向就比较麻烦一些了。因为船是没有办法在静止的状态原地打转的,因此船的转向速度的具体值是跟朝向方向的速度挂钩的,但两者又不是简单的线性递增关系,否则速度较快时移动会极为鬼畜。
本来想自定义一条曲线去调整这个线性关系的,想了想最终还是用了个简单粗暴的方法,定义一个转速最大时所达到的速度值,然后以船的当前速度和它的比率得到线性关系。同时自定义一条曲线来模拟水面阻力:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Move : MonoBehaviour
{
//阻力
float Resistance
{
get
{
return ResistanceCurve.Evaluate(CurVeloticy.magnitude);
}
}
public float enginePower = 100;
float speedAmount = 0f;
float acceleration
{
get
{
return Mathf.Max(0.1f, enginePower - (ShipMass + Resistance));
}
}
float RotateSpeed = 720;
//EngineState engineState;
Rigidbody rig;
public AnimationCurve ResistanceCurve = new AnimationCurve();
//base
public float ShipMass = 80f;
public float ShipLength = 20f;
//move
Vector3 PrevPosition;
Vector3 CurVeloticy;
Vector3 TargetVeloticy;
Vector3 ActualVeloticy;
Vector3 ForwardAmount;
float TurnAmount;
float ActualTurnAmount;
public float maxRotateVelotocy=2;
//control
public bool speedUp = false;
public bool turnLeft = false;
public bool turnRight = false;
void Start()
{
rig = GetComponent<Rigidbody>();
PrevPosition = transform.position;
ActualVeloticy = Vector3.zero;
ForwardAmount = transform.forward;
transform.position = new Vector3(transform.position.x, 0, transform.position.y);
}
private void FixedUpdate()
{
InputProcess();
InterpolationVeloticy();
MovementUpdate();
}
void InterpolationVeloticy()
{
ActualVeloticy = Vector3.Lerp(ActualVeloticy, TargetVeloticy, 0.005f);
ActualTurnAmount = Mathf.Lerp(ActualTurnAmount, TurnAmount, 0.1f);
}
void MovementUpdate()
{
rig.velocity = Quaternion.AngleAxis(ActualTurnAmount, transform.up) * ActualVeloticy;
if (rig.velocity.normalized != Vector3.zero)
{
transform.forward = rig.velocity.normalized;
}
CurVeloticy = transform.position - PrevPosition;
PrevPosition = transform.position;
}
void InputProcess()
{
if (speedUp)
{
TargetVeloticy = ForwardAmount * (speedAmount + acceleration);
}
else
{
TargetVeloticy = ForwardAmount * (Mathf.Max(0, speedAmount - Resistance));
}
float rotateRate = Mathf.Clamp(CurVeloticy.magnitude / maxRotateVelotocy, 0f, 1f);
float deg = RotateSpeed *rotateRate;
if (turnLeft)
{
TurnAmount -= deg * Time.deltaTime;
}
if (turnRight)
{
TurnAmount += deg * Time.deltaTime;
}
}
}
调整一下参数到一个合适的手感,效果如下:

因为暂时只是在平面上移动,没有水面效果的参照,先用粒子系统或者拖尾渲染器做一个简单的尾迹,然后让相机跟随船移动:
public GameObject Target;
public float smoothing = 3;
private void LateUpdate()
{
Vector3 tracePos = new Vector3(Target.transform.position.x, 100, Target.transform.position.z);
transform.position = Vector3.Lerp(transform.position, tracePos, smoothing * Time.deltaTime);
}

简易的AI
如果看了之前移动的代码,可以发现我把移动的输入部分抽象成了3个布尔值,分别是加速,左转和右转,其目的是方便写一个简单的AI去控制船,同时玩家控制部分也可以通过修改这三个布尔值实现。
这样一来AI的移动逻辑写起来就很简单了:

同理武器和炮塔脚本也稍微修改一下, 只传入一个目标对象作为瞄准的目标,用物理系统的球形检测当做“雷达”来寻找目标。
AI部分代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[DefaultExecutionOrder(1000)]
public class AIController : MonoBehaviour
{
Move movement;
weapons[] weapons;
TurretsContorl[] Turrets;
//寻敌范围
public float radiusForSearchTarget;
//目标距离的余量
public float stopDistence;
//目标夹角的余量
public float stopDegrees;
//目标
GameObject target = null;
void Start ()
{
movement = transform.GetComponent<Move>();
weapons = transform.GetComponentsInChildren<weapons>(true);
Turrets = transform.GetComponentsInChildren<TurretsContorl>(true);
}
void Update ()
{
SearchEnemy();
}
void SearchEnemy()
{
Collider[] col = Physics.OverlapSphere(transform.position, radiusForSearchTarget, 1 << LayerMask.NameToLayer("Player"));
//玩家只有一个,先不考虑多对多的战斗
if(col.Length>0)
target = col[0].gameObject ?? null;
Vector3 targetPos;
if (target != null)
{
targetPos = new Vector3(target.transform.position.x, transform.position.y, target.transform.position.z);
}
else
{
targetPos = Vector3.zero;
}
AIControl(target != null, targetPos);
}
void MoveToTarget(Vector3 targetPos_fixed)
{
float dis = Vector3.Distance(targetPos_fixed, transform.position);
if(dis>stopDistence)
{
movement.speedUp = true;
}
else
{
movement.speedUp = false;
}
}
void AimAtTarget(Vector3 targetPos_fixed)
{
for(int i=0;i<Turrets.Length;i++)
{
Turrets[i].targetPos = targetPos_fixed;
Turrets[i].Fire();
}
}
void RotateToTarget(Vector3 targetPos_fixed)
{
Vector3 tarDir =(targetPos_fixed - transform.position).normalized;
bool tarIsRight = Vector3.Cross(transform.forward, tarDir).y > 0;
float angle = Vector3.Angle(transform.forward, tarDir);
if(angle>stopDegrees)
{
if (tarIsRight)
{
movement.turnRight = true;
movement.turnLeft = false;
}
else
{
movement.turnRight = false;
movement.turnLeft = true;
}
}
else
{
movement.turnRight = false;
movement.turnRight = false;
}
}
void AIControl(bool hasTarget,Vector3 targetPos_fixed)
{
if(!hasTarget)
{
movement.speedUp = false;
movement.turnRight = false;
movement.turnLeft = false;
for(int i=0;i<Turrets.Length;i++)
{
Turrets[i].targetPos = targetPos_fixed;
}
return;
}
MoveToTarget(targetPos_fixed);
RotateToTarget(targetPos_fixed);
AimAtTarget(targetPos_fixed);
}
//寻敌范围可视
private void OnDrawGizmos()
{
Gizmos.color = Color.red;
Gizmos.DrawWireSphere(transform.position, radiusForSearchTarget);
}
}
没有目标时就先让它呆呆的什么都别做,先勾引一下AI测试反应:

勉强还能用,AI暂时就先这么凑合着,等后面游戏玩法结构搭起来了再考虑扩展或者修改。
武器系统扩展
原作多样的武器系统是玩法的核心,所以我们自然也不能只有简简单单的一门炮,得让武器系统丰富一些。
初步的想法是先添加上海战里常用的两种武器:导弹和鱼雷。
但在写的时候我突然意识到一个问题,目前的三种武器系统的发射方式和发射后的移动逻辑均不相同,除了炮塔旋转部分的脚本能够复用,这意味着算上子弹和发射器我总共要写6个脚本,但是其中很大一部分代码都是相同的。这样算起来不仅工作量大,而且后续也不好扩展。
作为一个懒癌晚期的患者我自然是很烦相同的代码要写几遍这种情况,所以是时候祭出面向对象的方法了。

首先把三者共同的部分抽象成发射器和投射物的基类,不同的部分如移动方式等用虚方法写出来并在子类里重载,以火炮继承发射器为例子:
发射器基类:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Emitter : MonoBehaviour
{
[SerializeField]
protected float FireFrequency = 0.4f;
protected float PrevFireTime = float.MinValue;
[SerializeField]
protected ParticleSystem[] Particles = new ParticleSystem[0];
[SerializeField]
protected Transform muzzle;
protected bool controllerIsPlayer = false;
[SerializeField]
protected TurretsContorl turrets;
[SerializeField]
protected GameObject projectile;
public float speed;
public float lifeTime;
public int damage;
public bool ReadyToShoot
{
get
{
if (controllerIsPlayer)
{
return PrevFireTime + FireFrequency < Time.time;
}
else
{
return (PrevFireTime + FireFrequency < Time.time) && turrets.inShootArea;
}
}
}
protected virtual void Start()
{
Transform root = GetRoot(transform);
if (root.tag == "Player")
{
controllerIsPlayer = true;
}
if (!turrets)
{
turrets = transform.GetComponentInParent<TurretsContorl>();
}
if (!muzzle)
{
muzzle = transform.Find("Muzzle").transform;
}
}
protected virtual void Update()
{
}
Transform GetRoot(Transform t)
{
if (t.parent == null)
{
return t;
}
else
{
return GetRoot(t.parent);
}
}
//基类的发射方法 ,先空着,在子类重载
protected virtual void Shoot()
{
}
public virtual void Fire()
{
if (ReadyToShoot)
{
Shoot();
PlayAllParticles();
PrevFireTime = Time.time;
}
}
void PlayAllParticles()
{
for (int i = 0; i < Particles.Length; i++)
{
Particles[i].Play();
}
}
#if UNITY_EDITOR
protected virtual void Reset()
{
ParticleSystem[] p = GetComponentsInChildren<ParticleSystem>(true);
Particles = p;
muzzle = transform.Find("Muzzle").transform;
turrets = transform.GetComponentInParent<TurretsContorl>();
}
#endif
}
还记得上一篇文章里我们用动画曲线实现了一个开炮退膛的小动画,基类的代码里并没有写,因为那是只有火炮才有的特殊方法,但是我们可以在子类的代码里实现。
重写后的子类火炮的代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Artillery : Emitter
{
[SerializeField]
private Transform model;
public Transform Model
{
get
{
return model ? model : transform.Find("Model").transform;
}
}
[SerializeField]
private AnimationCurve LerpCurve = AnimationCurve.EaseInOut(0f, -0.4f, 0.4f, 0f);
protected override void Update()
{
AnimUpdate();
}
void AnimUpdate()
{
float t = Time.time - PrevFireTime;
model.localPosition = Vector3.forward * LerpCurve.Evaluate(t);
}
protected override void Shoot()
{
Instantiate(projectile).GetComponent<Projectile>().Init(muzzle.position, muzzle.forward, speed, lifeTime, damage, gameObject.layer);
}
#if UNITY_EDITOR
protected override void Reset()
{
base.Reset();
model = transform.Find("Model").transform;
}
#endif
}

将子类脚本挂载到火炮上依然能正常的发射子弹了,我们再来实现其他两种武器系统。


我的想法是导弹发射器与火炮的发射方式不同在间隔和锁定目标,导弹一口气可以连着发射多发,然后会有一个较长的重装填时间,并且发射导弹时还会传入一个目标的坐标。同时导弹的速度会随着时间加速并追踪目标。
基于长度原因剩下的代码就不贴出来了,基本想法与上面并无太大区别,有兴趣的同学可以下载工程自行研究。直接看看完成以后的效果:


鱼雷我则是设定的同时发射多发,然后在给定的角度下以一个均匀的扇形弹道发射,并且速度是随时间递减的。


结束
看了下至今为止的成果,可以说已经颇具雏形了,成就感满满啊。

下一期文章的内容主要就是不同武器间攻击判定的逻辑,到时候我们的小船将能真正的战斗起来。
不出意外下一次更新应该就在十一之后了,这里先预祝大家国庆快乐。
限于水准文章肯定会有疏漏和不足,还希望大家能在评论中指正。
感谢观看到此,下期再见。(放心,绝对不会太监)

本期工程地址:https://github.com/tank1018702/unity-004
想系统学习游戏开发的童鞋,欢迎访问 http://levelpp.com/
游戏开发搅基QQ群:869551769
微信公众号:皮皮关