從零開始用Unity做一個海戰(zhàn)游戲(下)

作者:沈琰
本篇難度:★★★☆☆
前言
這個小工程終于也到最后一期了。為表示慶祝,換個風格的戰(zhàn)艦作為題圖。

還是先來個上期的傳送門:從零開始用Unity做一個海戰(zhàn)游戲(中)
如上期所講,這期的內(nèi)容主要是實現(xiàn)武器的攻擊邏輯,讓船之間能真正的戰(zhàn)斗起來。
投射物觸發(fā)檢測
自己做過類似游戲的人都知道,檢測碰撞信息通常都是物理系統(tǒng)中的剛體(Rigbody與碰撞器(Collider)搭配使用實現(xiàn)。
Unity中想要檢測到兩者間的碰撞信息需要均掛載碰撞器,然后至少得有一邊掛載剛體。之前在實現(xiàn)船的移動邏輯時已經(jīng)在船上加載過剛體了,那么現(xiàn)在子彈上就只需要碰撞器,所有的被攻擊判定放在船上實現(xiàn)。
想到這就出現(xiàn)了兩個問題:
1.處理同陣營間的攻擊邏輯
假設(shè)現(xiàn)在是一個主角對一群的敵人。敵人如果用的是導彈可能還好,若是魚雷或者火炮,在敵人數(shù)量眾多的情況下估計主角還沒被打死,就會有一堆敵人被自己人打成篩子。


當然,這個問題也能解決,復雜點的就是在AI上加一個躲避隊友射擊方向的移動邏輯。不過這想法太過南轅北轍,用腳指頭想想就覺得很麻煩,效果還不見得好,因此Pass。
簡單一點的方法是做碰撞檢測時先過濾一下層級,把發(fā)射出去的子彈打上自己的“標記”,當檢測到子彈碰撞信息時發(fā)現(xiàn)是自己人打出來的子彈就濾過去。換而言之就是把隊友誤傷關(guān)掉了,讓子彈直接穿過去,畢竟只是一個小項目,不用過多考慮真實性的問題。
2.子彈擊中船體以外其他碰撞器的處理
現(xiàn)在的命中邏輯有這么一個問題,如果武器擊中了其他碰撞器怎么辦?
打個比方,導彈的轉(zhuǎn)向角速度不夠,沒有命中敵船而是一頭鉆進了海里,如果這時候?qū)椨忠活^從水里鉆出來打中目標....,就算小工程不太講求真實性,可這樣看起來也太怪異了。
按道理導彈鉆進水里應(yīng)該直接就銷毀了,所以要額外在水面上掛個腳本去處理碰撞信息,不過擴展起來就很麻煩。
現(xiàn)在場景里是只有一個水面,以后還可能會有陸地、島嶼、陸地上的建筑等,總不能每次添加新的場景物體就再寫個腳本。
思來想去還是得把總的碰撞邏輯放在子彈上比較靠譜,所以檢測函數(shù)寫在子彈的基類上,然后在每一個場景物體中添加剛體,再根據(jù)檢測到的層級去分別處理。
——————————————————————————————————————
以上是常規(guī)方法,實際情況是在項目中使用剛體檢測物理碰撞非常消耗性能,所以對于類似子彈這樣的投射物一般自己寫代碼或使用射線檢測這樣性能消耗較小的方式。
在初學Unity時我曾用在子彈上掛剛體的辦法實現(xiàn)了一把槍的功能,當場景中有多把槍同時發(fā)射時編輯器就直接卡死了...,所以得用個折中的辦法去處理這個問題。
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);
??? }
}
?
以上是檢測碰撞部分的代碼,在上一期的文章里已經(jīng)計算出了投射物的運行軌跡,可以很方便的獲取投射物當前的方向和移動距離。使用球形投射檢測(Physics.SphereCastNonAlloc)的方式根據(jù)投射物的半徑在每一個固定幀去檢測移動方向上有沒有碰撞體。

就像是打出一條柱狀射線一樣,沒有使用射線檢測主要是考慮到可能會有半徑較大的投射物擦著碰撞體過去卻沒有檢測到碰撞的情況,也比較好調(diào)整各種不同類型的子彈的觸發(fā)判定范圍。
當然,球形投射要比射線檢測消耗更多的性能,參考這篇博客:Unity中各類物理投射性能橫向比較。若實際運行時有性能方面的問題再改回射線檢測也比較方便。
再根據(jù)投射物和擊中碰撞體的類型播放不同的粒子效果,同時額外寫一個腳本記錄船的生命值,用協(xié)程做一個簡單的沉船動畫,一個簡單的海上戰(zhàn)斗原型就算完成了:

AI目標的提前量與炮彈拋物線軌跡計算
現(xiàn)的AI還是顯得很“笨拙”,主要表現(xiàn)在火炮的命中上。只要玩家在移動,AI是永遠打不著玩家的:

所以這里要根據(jù)玩家的移動方向和速度計算一下提前量:

這時炮彈速度與船的速度都是已知的,實際距離也可以用坐標間的距離求得。雖然時間未知,但當擊中目標時時間相同的,故而炮塔當前朝向與船的朝向的夾角α的正切值實際是等于炮彈速度與船速的比,這樣通過三角函數(shù)換算可以得到一個一元二次方程,求解即可以得到命中的時間T。
本來到一步問題已經(jīng)解決了,已經(jīng)準備開始做一些收尾的工作了。當我準備捏一個大一些的船當BOSS時突然想到了一個問題:

其實一開始就想過讓炮彈以拋物線軌跡運動,當時嫌麻煩沒寫,但現(xiàn)在的問題是如果炮口位置是高于目標船的高度那就永遠都打不到了。
當然能用一些取巧的方法來解決,比如炮彈上再額外加一個向下的射線檢測,把攝像機調(diào)成俯視角后應(yīng)該是看不出來的。
不過看標題你也知道了,為了表現(xiàn)力更好一些還是選擇了更麻煩的計算拋物線彈道,逛了一圈等于又繞回來了。不過當拋物線與提前量計算結(jié)合起來,這個問題陡然就變得棘手起來。
——————————————————————————————————————
咱們把問題分解一步步來,首先是將炮彈的運行軌跡改成拋物線。因為炮彈上是沒有剛體的,這部分的運動邏輯要在代碼里自己來模擬,在子類里來修改一下移動邏輯:
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;
??? }??
}
?
把拋物線每個時間點的速度分解為水平方向和垂直方向來看,水平方向是勻速直線運動,而垂直方向是勻加速直線運動。

上面這段代碼的意思就是分開計算水平和垂直速度。
假設(shè)初始射擊角度平行于Z軸,那么水平速度就等于初始速度不變,再用時間算和重力加速度算出垂直方向是瞬時速度,在每個時間點上把兩個速度分量相加,得到的就是當前的實際速度。
先用和子彈同樣的邏輯在場景內(nèi)把這條拋物線畫出來,驗證一下計算是否準確。、
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);
??? }
?


可以看到計算應(yīng)該是沒有問題的,下一步就是根據(jù)這條拋物線來計算目標的提前量。
但加了一個維度后,解決這個問題就比之前要困難很多了。簡單來說就是沒辦法只通過一次計算來得到正確值了。

在不考慮什么空氣阻力之類的問題的情況下水平速度等于炮彈速度與出膛角度余弦值的乘積,當一開始以目標現(xiàn)在的位置計算時間時,得到的炮彈命中的時間實際是以之前的角度換算的水平速度計算出來的,而以這個時間參數(shù)計算出的目標提前量就不準確。因為實際的時間是要大于計算中的參數(shù)時間。
辦法就是用這個有誤差的時間計算出的預估提前量作為當前目標再次計算,如此反復多次直到上一次計算出的時間與當前計算出的誤差小于一個給定的精度。
從得到方法到寫進代碼里還得經(jīng)過一些數(shù)學推導計算,以本人的數(shù)學功底也就不在這獻丑了,對此處有興趣的同學可以參考這里:
游戲的物理和數(shù)學:Unity中的彈道和移動目標提前量計算
http://www.ceeger.com/forum/read.php?tid=3919&fid=2&page=1
計算部分的代碼:
? ? //accuracy(精度)給得越高,遞歸計算的次數(shù)也會越多
??? 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;
??? }
?
對于炮管來說就不用關(guān)心在XZ坐標系的瞄準方向問題了,只需要得到在YZ坐標系的俯仰角即可,再把得到的模擬坐標參數(shù)傳回給炮塔,讓炮塔轉(zhuǎn)向這個位置。
然后就是老辦法,用可視化的方法把模擬坐標位置顯示在場景里,驗證計算結(jié)果:

目測似乎問題不大,但還是得實際運行時檢驗一下??梢园褦橙藦椭茙讉€擺在不同的方位上測試一下整體的命中率:

......感覺難度一下又變得太大了,只要玩家的速度變化不太大,AI的命中率就很高。不過這個沒關(guān)系,可以再加個隨機參數(shù)去調(diào)整偏移值,就結(jié)果來說基本之前的目的是達到了。
游戲中的粒子效果
這部分本來是想詳細說一下,但是寫到這又覺得沒什么可說的。只要熟悉粒子系統(tǒng)的各個模塊功能,做點類似的簡單效果就沒什么難度,具體怎么做反倒是因人而異了。
但文章里又沒法詳細的去介紹這些模塊的使用,因此向大家推薦一個關(guān)于粒子系統(tǒng)的參考視頻:【特效制作學習】如何制作塞爾達傳說-荒野之息神廟特效【完結(jié)】,有興趣的同學可以自行研究一下。
關(guān)于這里就只說一個遇到的問題:
在實現(xiàn)魚雷水下爆炸的粒子效果時,想讓濺起的水花回落到海上有一個波紋效果。于是用到了粒子系統(tǒng)中的Triggers模塊。
但是發(fā)現(xiàn)模塊中的Colliders似乎無法在預制體中靜態(tài)加載,因此這里是在實例化時在腳本中賦值。

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));
??????? //設(shè)置水花擊中海面的trigger
??????? pc.transform.GetChild(0).GetComponent<ParticleSystem>().trigger.SetCollider(0, GameObject.Find("Sea").transform);??
??????? pc.Play();
??? }
?
這里并沒有讓生成的子粒子效果繼承任何屬性,理論上波紋的粒子效果應(yīng)該是初始角度,但實際擊中海面的角度有一些偏差:


目前暫時還未找到問題出在哪,不過俯視角中并不太影響,暫時先這么湊合了,如果哪位大佬知道希望能在評論中賜教。
結(jié)束
其實游戲以完成度來說還遠不夠,不過基本的結(jié)構(gòu)已經(jīng)搭建起來了。剩下的部分包括裝備系統(tǒng)的擴展、場景搭建、UI等等這些比較耗時且與基本結(jié)構(gòu)沒什么太大關(guān)系。
所以這個小游戲以后還會抽空繼續(xù)做下去,只不過文章部分就到此為止了,有興趣的同學也可以以此為基礎(chǔ)發(fā)揮想象力自行修改,感謝觀看到此。

本期工程地址:https://github.com/tank1018702/unity-004
最后想系統(tǒng)學習游戲開發(fā)的童鞋,歡迎訪問?http://levelpp.com/? ???
游戲開發(fā)攪基QQ群:869551769? ?
微信公眾號:皮皮關(guān)