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

作者:沈琰
前言
大家好。思索良久我決定鼓起勇氣開一個(gè)稍微大點(diǎn)的新坑。

初衷是因?yàn)楸救吮容^喜歡那種從無到有創(chuàng)造的樂趣,想做一個(gè)稍微完整點(diǎn)的小項(xiàng)目自娛自樂,也算是給自己一個(gè)小挑戰(zhàn)。所以這篇文章亦可看做是一個(gè)簡易的開發(fā)日志。
同時(shí)為了給喜歡游戲開發(fā),有志于投身于此的萌新們一些幫助,盡量不使用復(fù)雜的組件或插件,用代碼來實(shí)現(xiàn)需求。開發(fā)中遇到一些坑我也會盡力詳細(xì)的說明。目的是讓只要有一點(diǎn)C#編程基礎(chǔ)的萌新也能跟著完整的做出來。
不過就算是天馬行空的想法也得有個(gè)現(xiàn)實(shí)的參照,創(chuàng)世也得講個(gè)基本法吧?所以我也有個(gè)參考,原型來自于以前很喜歡玩的一款網(wǎng)頁小游戲《宇宙海賊王》:

游戲大體的玩法就是去扮演一個(gè)宇宙海賊,通過掠奪星球獲得更強(qiáng)的裝備直到挑戰(zhàn)海賊王。
游戲中的裝備系統(tǒng)非常豐富,多種流派之間的變化也讓游戲的可玩性極佳。
游戲地址:http://www.u77.com/game/1790
最終目標(biāo)就是在Unity上復(fù)刻一個(gè)類似的游戲出來,只不過題材從宇宙戰(zhàn)改成了海戰(zhàn),也許還會加入一些自己覺得好玩的點(diǎn)子進(jìn)去。
小船造起來
雖然想法很豐滿,但現(xiàn)實(shí)還是得從零開始一點(diǎn)一點(diǎn)的搭出來,就先從一艘簡單的小船開始。
為了對得起這個(gè)標(biāo)題所以咱干脆連素材也不要了,自己動手做。
只不過這可有些難為沒有美術(shù)細(xì)胞的我了,最初我想直接用Unity自帶的模型拼一個(gè)出來,結(jié)果是慘不忍睹,差點(diǎn)我就想棄坑了。

最后仔細(xì)想了下,如果建模這一步要自己來還得去求助一些工具。
但是面對繁雜的建模軟件頓時(shí)又生出一些無力感,這些軟件都需要花大量的時(shí)間和精力去學(xué)習(xí),對于只想簡簡單單做個(gè)小游戲的我而言頗有些本末倒置的感覺。
那么有沒有一款軟件既能做出還看得過去的模型,又簡單易用呢?有!
向大家安利一款功能強(qiáng)大且免費(fèi)開源的體素制作工具M(jìn)agicaVoxel,這里不詳細(xì)介紹軟件的使用方式了,因?yàn)槭钦娴暮唵我讓W(xué),就算沒有任何美術(shù)基礎(chǔ)也能輕松上手。
下載地址:http://ephtracy.github.io/
總之我花了幾個(gè)小時(shí)鼓搗了一通,總算拼出一艘船來。
出來吧,我的海軍夢幻號!

好吧,只能說勉強(qiáng)能看,可能有些美術(shù)出身的同學(xué)已經(jīng)忍不住要吐槽了,但不管怎么說總算比上面那個(gè)來的強(qiáng)。
在這里有個(gè)要注意的地方,在MagicaVoxel中的坐標(biāo)系的軸與Unity有些不一樣,對應(yīng)起來是Y->Z ,Z->Y,X軸則是一樣的。然后將文件導(dǎo)出為obj格式就可以拖到Unity里使用了。


二營長的意大利炮
可以看到特意在船上留了兩個(gè)炮座,所以下一步就是擼出一門炮來。這比起捏一艘船來可就簡單多了,只用注意一點(diǎn),把炮身和炮臺獨(dú)立分開來,至于為什么,容我先賣個(gè)關(guān)子。

然后回到Unity這里導(dǎo)入模型,拼裝在一起,如果你是真的分開來做炮塔和炮身,那在Unity里你要把兩者拼得嚴(yán)絲合縫會極其蛋疼。
這里說一個(gè)小技巧,先就把兩者一起畫出來保存一份,復(fù)制兩份在MagicaVoxel里分別扣去彼此。

然后在Unity中把炮身設(shè)為炮塔的子物體,把炮身的坐標(biāo)置零就行了。

現(xiàn)在先不慌把炮擺到船上去,我們先把開炮的效果做出來。
首先炮得有開火的間隔,這對有一定基礎(chǔ)的同學(xué)來說并不難。聲明一個(gè)float變量作為間隔時(shí)間,在Update里減去每幀時(shí)間,一旦小于零就是可以發(fā)射了。這里換個(gè)看起來更簡單的方法,
在炮身上新建一個(gè)腳本:
public class weapons : MonoBehaviour
{
??? //開火間隔時(shí)間
??? public float FireFrequency = 0.4f;
??? //上一次開火時(shí)間
?? ?private float PrevFireTime = float.MinValue;
?
??? //如果上次開火的時(shí)間加上開火間隔時(shí)間小于當(dāng)前游戲進(jìn)行的時(shí)間,則炮的冷卻好了
??? private bool CanShoot
??? {
??????? get
??????? {
??????????? return PrevFireTime + FireFrequency < Time.time ? true : false;
??????? }
??? }
??? void Update()
? ??{
??????? KeyUpdate();
??? }
??? void Fire()
??? {
??????? Debug.Log("開火");
??? }
??? void KeyUpdate()
??? {
??????? if (Input.GetKey(KeyCode.Mouse0))
??????? {
??????????? if (CanShoot)
??????????? {
??????????????? Fire();
??????????????? PrevFireTime = Time.time;
??????????? }
??????? }
??? }
}
?
是不是有一種簡明扼要的感覺?如果能開炮那就開炮吧。簡單的使用了C#的屬性讓代碼的可讀性提高了不少。
然后現(xiàn)在回到之前賣的關(guān)子上來,炮身和炮管分離是為了做一個(gè)簡單的開炮動畫。
可能提到動畫大多數(shù)人想到的都是動作酷炫人形動畫,但明顯不能用在這里。
Unity里提供了一個(gè)非常實(shí)用的功能:AnimationCurve,作用是編輯一條曲線用在任何你能使用的地方。我們需要的是用這條曲線模擬大炮開炮時(shí)退膛的動畫。
先在腳本中新建一條動畫曲線,然后就可以在腳本的面板上編輯它了。
public AnimationCurve LerpCurve = new AnimationCurve();?

編輯界面有幾條預(yù)設(shè)的曲線,我們選取其中一條先緩后急的稍作修改。
這條曲線代表的是值隨著時(shí)間變化的關(guān)系,我們需要開炮的時(shí)候炮身急速后退,然后緩慢回膛的效果,因此拖動右邊的端點(diǎn)到(0.4,0),左邊到(0,-0.4)左右,然后在腳本里獲取炮身的模型,讓其坐標(biāo)的Z值隨著這條曲線變化。
void PositionUpdate()
??? {
??????? float t = Time.time - PrevFireTime;
??????? model.localPosition = Vector3.forward * LerpCurve.Evaluate(t);
??? }
?
如圖,大致就是我們所要的效果:

再添加一些開炮的粒子效果,讓表現(xiàn)力提高一點(diǎn)

炮塔旋轉(zhuǎn)控制
現(xiàn)在可以把炮搬到船上去啦。
首先在船的父節(jié)點(diǎn)上新建兩個(gè)子節(jié)點(diǎn)作為炮的錨點(diǎn),調(diào)整子節(jié)點(diǎn)的坐標(biāo)分別與船上炮座的底部中心點(diǎn)吻合,然后把炮塔設(shè)為錨點(diǎn)的子物體,坐標(biāo)歸零。

大家都見過真實(shí)的戰(zhàn)艦炮塔是怎么運(yùn)動的:
1.炮塔是有轉(zhuǎn)速的。
2.炮管是可以沿著X軸方向抬升下降的。
3.炮塔的轉(zhuǎn)動是有限制角度的。
我們先簡化一下難度,暫時(shí)不考慮炮管在X軸轉(zhuǎn)動的問題。所以現(xiàn)在要實(shí)現(xiàn)的功能就是在限制角度內(nèi)恒定速度旋轉(zhuǎn)的炮塔。
勻速旋轉(zhuǎn)這個(gè)問題不難,限制每幀的角速度就行,限制旋轉(zhuǎn)角度這個(gè)問題就稍微麻煩一些了。
我們先假設(shè)把炮塔限制在正前方60度內(nèi)轉(zhuǎn)動。

我一開始的想法是計(jì)算炮塔當(dāng)前朝向角度,如果超過限制角的一半則停止轉(zhuǎn)動。
想法是沒錯(cuò)的,但是有一個(gè)問題。在Unity里判斷當(dāng)前的歐拉角會有角度正負(fù)的區(qū)別,比如一個(gè)物體沿Y軸選轉(zhuǎn)1度和旋轉(zhuǎn)-359度,最后的朝向是一樣的,但是旋轉(zhuǎn)方向不同。這會讓代碼里角度判定條件出錯(cuò)。
所以換了個(gè)思路來解決這個(gè)問題。
public class TurretsContorl : MonoBehaviour
{
??? [Range(30, 330)]
??? public float LimitAngle = 60f;
?
??? public int RotateSpeed = 180;
?
??? public Camera cam;
?
??? void Update()
??? {
??????? RotateUpdate();
??? }
?
??? void RotateUpdate()
??? {
??????? Vector3 limit_dir = GetMouseDir_limit(LimitAngle);
?
??????? Quaternion rotate = Quaternion.RotateTowards(transform.rotation,
???????????????????????????????????????????????????? Quaternion.LookRotation(limit_dir, transform.up),
?????????????????????????????????? ?????????????????RotateSpeed * Time.deltaTime);
??????? transform.rotation = rotate;
??? }
?
??? //獲取限制角度內(nèi)的方向
??? Vector3 GetMouseDir_limit(float limit_angle)
??? {
??????? Ray ray = cam.ScreenPointToRay(Input.mousePosition);
??????? RaycastHit hit;
?
????? ??if (Physics.Raycast(ray, out hit))
??????? {
??????????? Vector3 hitpos = new Vector3(hit.point.x, transform.position.y, hit.point.z);
??????????? Vector3 dir = (hitpos - transform.position).normalized;
??????????? Debug.DrawLine(hitpos, transform.position, Color.blue, 0.5f);
?
??????????? //如果鼠標(biāo)指向的方向超過了限制角的一半,返回限制角與父節(jié)點(diǎn)Z方向的乘積
??????????? if (Vector3.Angle(transform.parent.forward, dir) > limit_angle / 2)
??????????? {
??????????????? bool in_my_right = Vector3.Cross(transform.parent.forward, dir).y > 0;
??????????????? Quaternion q = Quaternion.AngleAxis((in_my_right ? limit_angle : -limit_angle) / 2, transform.parent.up);
??????????????? Debug.DrawRay(transform.position, q * transform.parent.forward * 5, Color.red, 0.5f);
??????????????? return q * transform.parent.forward;
??????????? }
??????????? else
??????????? {
??????????????? Debug.DrawRay(transform.position, (hitpos - transform.position).normalized * 5, Color.green, 0.5f);
??????????????? return (hitpos - transform.position).normalized;
??? ????????}
??????? }
??????? else
??????? {
??????????? return transform.parent.forward;
??????? }
??? }
}
?
直接在獲取目標(biāo)向量時(shí)就計(jì)算好,在旋轉(zhuǎn)時(shí)直接按恒定速度轉(zhuǎn)就可以了。

藍(lán)線表示鼠標(biāo)到炮塔的向量,黃線限制角內(nèi)的返回的目標(biāo)向量,紅線為超過限制角的目標(biāo)向量??梢钥吹酱笾路衔覀兊南敕?但是還沒有完全解決,當(dāng)把限制角加到一個(gè)大于180度的角度時(shí)出現(xiàn)了新的問題。

輔助線告訴我們獲取的目標(biāo)向量沒有問題,問題出在了 Quaternion.LookRotation()這個(gè)函數(shù)上。計(jì)算時(shí)返回的是向量的兩個(gè)夾角中較小的那個(gè)角度。但在我們的需求中,這塊不能轉(zhuǎn)向的區(qū)域是“禁區(qū)”,所以得換一個(gè)方法來解決。
還是用圖來幫助我們進(jìn)行思考,假設(shè)現(xiàn)在的限制角是約300度:

由前面的結(jié)果可知當(dāng)限制角在正前方180度(綠色區(qū)域)時(shí)沒有問題,而無論是目標(biāo)向量還是炮塔的當(dāng)前朝向落在藍(lán)色區(qū)域中時(shí),我們就需要特殊處理一下了。
具體思路是當(dāng)目標(biāo)向量不在綠色區(qū)域時(shí),先判斷炮塔的當(dāng)前朝向轉(zhuǎn)向目標(biāo)向量的方向。如果這個(gè)旋轉(zhuǎn)方向朝向紅色區(qū)域,再判斷朝向與限制角邊界的夾角和朝向與目標(biāo)向量夾角之間的大小關(guān)系,可得知旋轉(zhuǎn)的路徑是否會經(jīng)過“禁區(qū)”。一旦條件成立,取反當(dāng)前的轉(zhuǎn)軸與角度。換成代碼表示如下:
void RotateUpdate()
??? {
??????? Vector3 aixs;
??????? float angle;
?
??????? Vector3 limit_dir = GetMouseDir_limit(LimitAngle);
?
??????? //目標(biāo)向量與基準(zhǔn)Z軸正方向的左右關(guān)系
??????? bool tar_is_right = Vector3.Cross(limit_dir, transform.parent.forward).y > 0;
??????? //炮塔當(dāng)前朝向與目標(biāo)向量之間的左右關(guān)系
??????? bool cur_is_right = Vector3.Cross(transform.forward, limit_dir).y > 0;
??????? //當(dāng)前朝向與基準(zhǔn)正方向的左右關(guān)系
??????? bool tra_is_right = Vector3.Cross(transform.forward, transform.parent.forward).y > 0;
?
??????? //如果目標(biāo)向量在正180度之外,作特殊處理
??????? if (Vector3.Dot(limit_dir, transform.parent.forward) <= 0)
??????? {
??????????? //當(dāng)前朝向的邊界向量
??????????? Vector3 edge = Quaternion.AngleAxis(LimitAngle / 2, tra_is_right ? transform.parent.up : -transform.parent.up) * transform.parent.forward;
??????????? //朝向與邊界的夾角
??????????? float edge_angle = Vector3.Angle(transform.forward, edge);
??????????? //轉(zhuǎn)向可能經(jīng)過基準(zhǔn)負(fù)方向且目標(biāo)與邊界的夾角小于朝向與邊界的夾角,則角度和軸作取反處理
??????????? if (((tar_is_right && cur_is_right) || (!tar_is_right && !cur_is_right)) && Vector3.Angle(limit_dir, edge) < edge_angle)
??????????? {
??????????????? angle = 360 - Vector3.Angle(transform.forward, -limit_dir);
??????????????? aixs = Vector3.Cross(transform.forward, -limit_dir);
??????????????
??????????? }
??????????? else
??????????? {
??????????????? angle = Vector3.Angle(transform.forward, limit_dir);?????????
??????????????? aixs = Vector3.Cross(transform.forward, limit_dir);??????????
??????????? }????
??????? }
??????? else
??????? {
??????????? angle = Vector3.Angle(transform.forward, limit_dir);
??????????? aixs = Vector3.Cross(transform.forward, limit_dir);
?????
??????? }
??????? transform.Rotate(aixs, Mathf.Min(RotateSpeed * Time.deltaTime, angle));
??? }
?
最終效果如我們所愿:

結(jié)束
在這期文章中我們把船的基本結(jié)構(gòu)建立起來了,可以說開了個(gè)還算不錯(cuò)的頭。
可能文章在解釋遇到問題的時(shí)候稍顯拖沓,但我始終覺得在開發(fā)時(shí)遇到和解決問題的過程才是最有價(jià)值的,因此使用這種有點(diǎn)記流水賬的方式說明問題。同時(shí)限于自身的水平,難免會有疏漏和不足,也歡迎大家指正。
感謝觀看到此,下期再見。(如果沒有太監(jiān)掉的話...)
本期工程地址:https://github.com/tank1018702/unity-004
最后想系統(tǒng)學(xué)習(xí)游戲開發(fā)的童鞋,歡迎訪問?http://www.levelpp.com/
游戲開發(fā)攪基QQ群:869551769?
微信公眾號:皮皮關(guān)