如何在游戲里當(dāng)好一個(gè)反派——用Unity簡(jiǎn)單復(fù)刻《勇者別囂張》(下)

作者:沈琰
本篇難度:★★☆☆☆
前言
接著上期開(kāi)始下一階段的內(nèi)容。
上期傳送門(mén):如何在游戲里當(dāng)好一個(gè)反派——用Unity簡(jiǎn)單復(fù)刻《勇者別囂張》(上)
地圖搭建完畢后就應(yīng)該是游戲的核心玩法:魔物和勇者的行為邏輯實(shí)現(xiàn)了。
(上期評(píng)論說(shuō)美少女別囂張的,你們賠我的童年記憶啊,我現(xiàn)在已經(jīng)沒(méi)辦法直視這個(gè)游戲了......)

功能分析
魔物行為邏輯
這個(gè)看似休閑的小游戲某種意義上來(lái)說(shuō)相當(dāng)硬核,怎么說(shuō)?
原版游戲里的魔物生態(tài)規(guī)則看似簡(jiǎn)單,實(shí)則復(fù)雜而嚴(yán)謹(jǐn),小時(shí)候因?yàn)闆](méi)有漢化,玩得糊里糊涂,基本是很難撐到5關(guān)以后,到后來(lái)看了攻略才明白。
簡(jiǎn)單來(lái)說(shuō)魔物之間除了簡(jiǎn)單的移動(dòng)攻擊行動(dòng)外還存在著一條生態(tài)鏈,高級(jí)魔物吃低級(jí)魔物除了補(bǔ)充生命值外還能促進(jìn)繁殖,魔物的生命值即使沒(méi)有遭受攻擊也會(huì)隨著時(shí)間慢慢減少。
生成魔物的種類又與磚塊吸收的養(yǎng)分和魔份有關(guān)系,處于魔物生態(tài)鏈底端的史萊姆和鬼火負(fù)責(zé)養(yǎng)分和魔份的供給。所以想要能抵御越來(lái)越強(qiáng)的勇者進(jìn)攻,較為穩(wěn)定的生態(tài)鏈?zhǔn)且粋€(gè)金字塔形狀:

至于其他更復(fù)雜的養(yǎng)殖催生魔物進(jìn)化等等就不展開(kāi)說(shuō)了,都快變成游戲攻略了。
總之就是游戲的魔物行為邏輯沒(méi)有看起來(lái)那么簡(jiǎn)單,加上缺少動(dòng)畫(huà)素材,所以復(fù)刻時(shí)只選擇了一些重點(diǎn)功能(主要是因?yàn)閼校?。歸納一下就是:
1.移動(dòng)
2.養(yǎng)分運(yùn)送
3.攻擊
4.食物鏈(大怪物吃小怪物)
5.隨時(shí)間減少的生命力
勇者行為邏輯
相較來(lái)說(shuō)勇者的行為邏輯就簡(jiǎn)單多了,就是找魔王->干掉路上遇到的魔物->找到魔王->捆回家這么個(gè)過(guò)程。關(guān)鍵就在這個(gè)找魔王上,有經(jīng)驗(yàn)的同學(xué)大概能想到這里可能會(huì)用到尋路算法,但具體用哪一種以及怎么用?
基于觀察原版游戲勇者的尋找魔王得出的規(guī)律:
1.勇者會(huì)走到岔路里。
2.如果有多個(gè)勇者,在分岔路口會(huì)主動(dòng)分路尋找。
3.如果進(jìn)入死路,會(huì)沿著原路返回到上一個(gè)擁有其他未探尋岔路的節(jié)點(diǎn)繼續(xù)尋找。
其實(shí)到這結(jié)論已經(jīng)呼之欲出了,這種一條道走到黑的尋路方法與之最接近的是深度優(yōu)先搜索算法(Depth-First-Search),接下來(lái)所要做的就是把尋路的過(guò)程通過(guò)勇者的行動(dòng)顯示在游戲界面中。
角色移動(dòng)邏輯
先寫(xiě)移動(dòng)邏輯是因?yàn)檫@是魔物與勇者通用的方法,在上期文章實(shí)現(xiàn)鼠標(biāo)點(diǎn)擊邏輯時(shí)賣(mài)了一個(gè)關(guān)子:為什么選擇了一個(gè)坐標(biāo)系轉(zhuǎn)換自定義二維數(shù)組的方法?因?yàn)橐苿?dòng)邏輯同樣也以此作為基礎(chǔ)。

因?yàn)橛螒蚶飯?chǎng)景內(nèi)的物體都是遵循TileMap里網(wǎng)格大小的正方形,所以把世界坐標(biāo)轉(zhuǎn)換為我們自定義的二維數(shù)組坐標(biāo)后,移動(dòng)相關(guān)的邏輯就變成一個(gè)類似控制臺(tái)小游戲的移動(dòng)邏輯了。
假設(shè)現(xiàn)在要從游戲地圖里的藍(lán)點(diǎn)沿著最短曼哈頓距離移動(dòng)到紅點(diǎn),如果沒(méi)有這個(gè)自定義的二維坐標(biāo),計(jì)算起來(lái)會(huì)非常麻煩。而現(xiàn)在就簡(jiǎn)單了,設(shè)藍(lán)點(diǎn)為(0,0),右和上分別為X,Y的正方向,那么移動(dòng)到紅點(diǎn)的路徑則可以表示為:(0,0)->(0,-1)->(0,-2)->(0,-3)->(1,-3)->(2,-3),再用之前寫(xiě)好的轉(zhuǎn)換函數(shù)得到場(chǎng)景內(nèi)的實(shí)際坐標(biāo),兩點(diǎn)之間用差值計(jì)算移動(dòng)過(guò)程。

實(shí)現(xiàn)代碼:
protected IEnumerator Move(Vector2 dir)
??? {
??????? Vector2 correctionPos = Pos.Pos2Vector2(Pos.Float2IntPos(transform.position));
??????? Vector2 endPos = correctionPos + (dir * 0.18f);
??????? if (!isMoving)
??????????? yield return SmoothMovement(endPos);
??? }
?
??? protected IEnumerator MoveTo(Vector2 pos)
??? {
??????? if (!isMoving)
??????????? yield return SmoothMovement(pos);
??? }
?
IEnumerator SmoothMovement(Vector2 endPos)
??? {
??????? isMoving = true;
?
??????? float Distance = Vector2.Distance(new Vector2(transform.position.x, transform.position.y), endPos);
?
?
??????? while (Distance > float.Epsilon)
??????? {
??????????? Vector2 newPos = Vector2.MoveTowards(new Vector2(transform.position.x, transform.position.y), endPos, MoveSpeed * Time.deltaTime);
??????????
??????????? transform.position = newPos;
?
??????????? Distance = Vector2.Distance(new Vector2(transform.position.x, transform.position.y), endPos);
??????? }
??????
??????? isMoving = false;
??????? yield return null;
??? }
?
以上是純移動(dòng)邏輯,在之后加上動(dòng)畫(huà)狀態(tài)機(jī)運(yùn)行效果如下:

魔物狀態(tài)機(jī)
在上面的分析中已經(jīng)把魔物所需要的的功能都羅列出來(lái)了,其中移動(dòng)已經(jīng)實(shí)現(xiàn)了?,F(xiàn)在要做的就是把剩下的功能實(shí)現(xiàn)并用狀態(tài)機(jī)組裝成魔物的AI。
狀態(tài)機(jī)大家應(yīng)該不陌生了,不熟悉的同學(xué)可以先看看專欄里關(guān)于AI狀態(tài)機(jī)的教程。
傳送門(mén):給貓看的游戲AI實(shí)戰(zhàn)(三)基于狀態(tài)機(jī)的AI系統(tǒng)
首先把魔物的動(dòng)畫(huà)狀態(tài)機(jī)做出來(lái):

很簡(jiǎn)單的四方向動(dòng)畫(huà),在待機(jī)->移動(dòng)->攻擊之間切換,然后思考一下魔物狀態(tài)機(jī)的流程:

大概流程就是這樣,這一步大家自己實(shí)現(xiàn)時(shí)不必完全一樣。
表現(xiàn)到代碼里其實(shí)很簡(jiǎn)單,首先是單方向的障礙檢測(cè)函數(shù),根據(jù)layer返回檢測(cè)結(jié)果:
??Vector2 Direction2Vector2(Direction dir)
??? {
??????? Vector2 direction = Vector2.zero;
??????? switch (dir)
??????? {
??????????? case Direction.Up:
??????????????? direction = Vector2.up;
??????????????? break;
??????????? case Direction.Down:
?????? ?????????direction = Vector2.down;
??????????????? break;
??????????? case Direction.Left:
??????????????? direction = Vector2.left;
??????????????? break;
??????????? case Direction.Right:
??????????????? direction = Vector2.right;
??????????????? break;
??????? }
??????? return direction;
??? }
? protected bool ObstacleCheck(Direction dir, LayerMask layer)
??? {
??????? RaycastHit2D[] hit;
??????? hit = Physics2D.RaycastAll(transform.position, Direction2Vector2(dir), 0.18f, layer);
??????? return hit.Length > 0;
??? }
?
然后是各個(gè)行為函數(shù):
? ? protected IEnumerator CheckAndAttackEnemyAround()
??? {
??????? Collider2D[] arounds = Physics2D.OverlapCircleAll(transform.position, 0.09f, Enemylayer);
?
??????? if(arounds.Length>0)
??????? {
???????????
??????????? targetDir = GetDirection(transform.position, arounds[0].transform.position);
??????????? yield return Attack(targetDir);
?? ???????
??????? }
??????
??? }
?protected IEnumerator Attack(Direction dir)
??? {
??????? RaycastHit2D[] hit;
?
???????
??????? while (ObstacleCheck(dir, Enemylayer, out hit))
??????? {
??????????? _animator.SetBool("MoveState", false);
?
??????????? _animator.SetInteger("Dir", (int)dir);
??????????? yield return null;
?
??????????? _animator.SetTrigger("Attack");
?
??????????? for (int i = 0; i < hit.Length; i++)
??????????? {
??????????????? Character c = hit[i].transform.GetComponent<Character>();
?? ?????????????if (c)
??????????????? {
??????????????????? c.OnBeHit(damage);
??????????????? }
??????????? }
??????????? yield return AttackInterval;
??????? }
??? }
?IEnumerator Idle()
??? {
??????? float time = idleTime;
??????? while(time>float.Epsilon)
??????? {
??????????? time -= Time.deltaTime;
??????????? CheckAndAttackEnemyAround();
??????????? yield return null;
??????? }
??? }
??? protected virtual IEnumerator TransportNutrients()
??? {
??????? RaycastHit2D[] hit;
??????? if (ObstacleCheck(CurDir, WallLayer, out hit))
??????? {
??????????? _animator.SetTrigger("Attack");
?
??????????? Block script = hit[0].transform.GetComponent<Block>();
??????????? if (script)
??????????? {
??????????????? script.ChangeNutrient(nutrient);
??????????? }
?
??????? }
??????? yield return IdleTime;
??? }
?
然后把流程邏輯寫(xiě)在一整個(gè)協(xié)程函數(shù)中:
protected virtual IEnumerator Action()
??? {
??????? while (true)
??????? {
??????????? RandomDir();
?
??????????? if (Random.Range(0, 4) > 2)
??????????? {
??????????????? yield return Idle();
??????????? }
????????? ??else
??????????? {
??????????????? yield return null;
??????????????? if (ObstacleCheck(CurDir, TempLayer))
??????????????? {
??????????????????? yield return Behaviour();
?
??????????????????? continue;
?
??????????????? }
??????????????? yield return Move(CurDir);
??????????? }
??????? }
??? }
?
應(yīng)該有人注意到了所有行為邏輯的代碼都是協(xié)程,這里為什么要用到協(xié)程?
因?yàn)椴还苁且苿?dòng)過(guò)程也好,動(dòng)畫(huà)播放也好都要涉及到時(shí)間,也就是每個(gè)單獨(dú)的邏輯函數(shù)可能執(zhí)行時(shí)間都不一樣,全部用協(xié)程實(shí)現(xiàn)會(huì)讓狀態(tài)轉(zhuǎn)移的時(shí)候更加方便。
所以整個(gè)AI本質(zhì)上是一個(gè)協(xié)程狀態(tài)機(jī),然后把魔物的預(yù)制體填入磚塊生成中,運(yùn)行試一試效果:

勇者尋路邏輯
與魔物不同,勇者的AI其實(shí)就是一個(gè)尋路的過(guò)程,在移動(dòng)過(guò)程中如果遇到了魔物就會(huì)暫時(shí)中斷并與魔物展開(kāi)戰(zhàn)斗,所以攻擊的行為是寫(xiě)在移動(dòng)過(guò)程里的。剩下要做的事就是把這個(gè)尋找的過(guò)程表現(xiàn)出來(lái)。
之前分析的時(shí)候說(shuō)最接近這個(gè)表現(xiàn)形式的尋路算法是DFS,為什么這么說(shuō)呢?把每個(gè)格子看做一個(gè)節(jié)點(diǎn),當(dāng)前格子能去往下一個(gè)格子的路線看做分支,那么整個(gè)地圖可以看做是一張無(wú)向圖:

再看看《算法導(dǎo)論》中關(guān)于DFS的說(shuō)明:
深度優(yōu)先搜索算法所使用的策略就像其名字所隱含的:只要可能,就在圖中盡量“深入”。深度優(yōu)先搜索總是對(duì)最近才發(fā)現(xiàn)的結(jié)點(diǎn)v的出發(fā)邊進(jìn)行探索,直到該結(jié)點(diǎn)的所有出發(fā)邊都被發(fā)現(xiàn)為止。
一旦節(jié)點(diǎn)v的所有出發(fā)邊都被發(fā)現(xiàn),搜索則"回溯"到v的前驅(qū)結(jié)點(diǎn)(v是經(jīng)過(guò)該節(jié)點(diǎn)才被發(fā)現(xiàn)的),來(lái)搜索該前驅(qū)結(jié)點(diǎn)的出發(fā)邊。該過(guò)程一直持續(xù)到從源節(jié)點(diǎn)可以達(dá)到的所有結(jié)點(diǎn)都被發(fā)現(xiàn)為止。
如果還存在尚未發(fā)現(xiàn)的結(jié)點(diǎn),深度優(yōu)先搜索將從這些未被發(fā)現(xiàn)的結(jié)點(diǎn)中任選一個(gè)作為新的源節(jié)點(diǎn),并重復(fù)同樣的搜索過(guò)程。該算法重復(fù)整個(gè)過(guò)程,直到圖中的所有結(jié)點(diǎn)都被發(fā)現(xiàn)為止。
簡(jiǎn)單點(diǎn)來(lái)說(shuō)就是在每個(gè)結(jié)點(diǎn)探尋到新的路,都以那條最后找到的路為最優(yōu)先級(jí)走到黑,一旦無(wú)路可走再返回到之前還有未探尋路徑的結(jié)點(diǎn)繼續(xù)走到黑,這恰好與分析中原版游戲中的勇者尋路規(guī)律一模一樣。
把游戲里的地圖轉(zhuǎn)換成結(jié)點(diǎn)圖來(lái)看如下:

假如現(xiàn)在起點(diǎn)是A,終點(diǎn)是B,在結(jié)點(diǎn)1的位置進(jìn)入向下的岔路,則會(huì)一直走到C結(jié)點(diǎn)。然后原路返回至1,再探尋其他的路直到找到B為止。
把尋路算法用步驟用代碼表示出來(lái)如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
?
public class Hero : Character
{
?
??? Dictionary<Pos, bool> map;
??? Pos target;
?
??? bool IsFindTarget;
?
? IEnumerator HeroSearchRoad(Pos next)
??? {
??????? //把當(dāng)前世界坐標(biāo)轉(zhuǎn)換為二維數(shù)組坐標(biāo)
??????? Pos cur = Pos.Float2IntPos(transform.position);
???????
?????
??????? //如果找到目標(biāo),跳出遞歸
??????? if (next.Equals(target))
??????? {
??????????? IsFindTarget = true;
??????????? yield return new WaitForSeconds(2f);
??????????? yield break;
??????? }
??????? //移動(dòng)到下一個(gè)目標(biāo)點(diǎn)
??????? yield return MoveTo(Pos.Pos2Vector2(next));
?
??????? //字典中標(biāo)記下一個(gè)坐標(biāo)點(diǎn)為已探尋,防止回旋繞路
??????? map[next] = false;
?
??????? //獲取當(dāng)前位置能移動(dòng)到的其他節(jié)點(diǎn)坐標(biāo)
??????? List<Pos> curNode = GetCurrentNode();
?
??????? //根據(jù)與目標(biāo)距離對(duì)節(jié)點(diǎn)目標(biāo)進(jìn)行排序(可選)
??????? if (curNode.Count > 1)
??????? {
??????????? curNode.Sort((a, b) => Pos.GetManhattanDistance(a, target).CompareTo(Pos.GetManhattanDistance(b, target)));
??????? }
?
???????
??????? //沿著所有開(kāi)辟出的新節(jié)點(diǎn)尋路
??????? for (int i = 0; i < curNode.Count; i++)
??????? {
??????????? //找到目標(biāo)就不繼續(xù)其他節(jié)點(diǎn)的探索了
??????????? if (IsFindTarget)
??????????? {
??????????????? break;
??????????? }
??????????? yield return HeroSearchRoad(curNode[i]);
?
??????? }
?
??????? //所有能走的節(jié)點(diǎn)都走完了,只能回頭
??????? //回溯
??????? yield return MoveTo(Pos.Pos2Vector2(cur));
??? }
?
?
?
List<Pos> GetCurrentNode()
??? {
??????? List<Pos> list = new List<Pos>();
?
??????? List<Pos> resut = new List<Pos>();
?
??????? Pos cur = Pos.Float2IntPos(transform.position);
?
??????? if (!ObstacleCheck(Direction.Up, WallLayer))
??????? {
??????????? list.Add(new Pos(cur.x, cur.y + 1));
?
??????? }
??????? if (!ObstacleCheck(Direction.Down, WallLayer))
??????? {
??????????? list.Add(new Pos(cur.x, cur.y - 1));
?
???? ???}
??????? if (!ObstacleCheck(Direction.Right, WallLayer))
??????? {
??????????? list.Add(new Pos(cur.x + 1, cur.y));
?
??????? }
??????? if (!ObstacleCheck(Direction.Left, WallLayer))
??????? {
??????????? list.Add(new Pos(cur.x - 1, cur.y));
?
??????? }
??????? for (int i = 0; i < list.Count; i++)
??????? {
??????????? if (!map.ContainsKey(list[i]))
??????????? {
??????????????
??????????????? resut.Add(list[i]);
??????????????? map.Add(list[i], true);
??????????? }
??????????? else
??????????? {
?????? ????????
??????????????? if (map[list[i]] == true)
??????????????? {
??????????????????? resut.Add(list[i]);
??????????????? }
??????????? }
??????? }
??????? return resut;
??? }
}
?
代碼里除了原本的邏輯,額外多加了一步:在每次探尋下一個(gè)結(jié)點(diǎn)時(shí)優(yōu)先選擇相對(duì)目標(biāo)點(diǎn)最近的那一個(gè),所以最后勇者的尋路算法就變成了有距離指導(dǎo)的DFS。
弄個(gè)稍微復(fù)雜點(diǎn)的地圖,運(yùn)行的效果如下:

關(guān)于尋路算法更詳細(xì)的教程可以參考專欄里的另一篇文章。
給貓看的游戲AI實(shí)戰(zhàn)(四)眼見(jiàn)為實(shí)——讓AI的思考過(guò)程可視化
結(jié)尾
至此游戲雖不完整,但主體邏輯已經(jīng)還原的七七八八了。限于篇幅,還有一些參數(shù)調(diào)整和提升表現(xiàn)力的工作就由大家自己去嘗試,或者更改邏輯,定制出專屬自己的規(guī)則。


通過(guò)這兩期的簡(jiǎn)單復(fù)刻,可以發(fā)現(xiàn)這樣簡(jiǎn)單的小游戲其實(shí)蘊(yùn)含了相當(dāng)多的細(xì)節(jié)在里面,雖然整體算不上太難,但積累起來(lái)就很可觀了。
我們只是把最基本的核心邏輯還原就費(fèi)了不少功夫,更別說(shuō)原版游戲還有更多游戲性上的細(xì)節(jié)工作。現(xiàn)在的游戲畫(huà)面日趨精良,但像這樣設(shè)計(jì)師的心思都花在玩法上的游戲反而少了。
扯得有些遠(yuǎn)了,但愿能有更多像這樣能帶給玩家最本質(zhì)的樂(lè)趣的游戲吧,感謝觀看至此。
本期工程地址:https://github.com/tank1018702/unity-005
最后想系統(tǒng)學(xué)習(xí)游戲開(kāi)發(fā)的童鞋,歡迎訪問(wèn)?http://levelpp.com/????
游戲開(kāi)發(fā)攪基QQ群:869551769????
微信公眾號(hào):皮皮關(guān)