Unity官方教程 2D Roguelike(3):移動(dòng)邏輯

2D Roguelike 最終效果

前言

Unity官方教程 2D Roguelike(2):生成關(guān)卡中,我們已經(jīng)生成了隨機(jī)關(guān)卡,接下來就是讓大胡子可以在關(guān)卡里自由自在走動(dòng)。這一節(jié)我們主要完成的內(nèi)容是:

  • 基本移動(dòng)邏輯
  • 角色獲取輸入進(jìn)行移動(dòng)

本節(jié)你將學(xué)會(huì)什么?

  • 認(rèn)識(shí)父類(基類)/子類(派生類)/抽象類/抽象方法/虛方法
  • 認(rèn)識(shí)泛型函數(shù)
  • 如何通過協(xié)程進(jìn)行平滑移動(dòng)
  • 如何利用線性投射Linecast()檢測(cè)碰撞
  • 如何獲取輸入并且進(jìn)行移動(dòng)

一、實(shí)現(xiàn)基本移動(dòng)邏輯——編寫父類MovingObject

最終的游戲效果我們可以看到,大胡子和怪物雖然種族樣貌均不同,但是它們?cè)谝苿?dòng)這塊存在很多共同點(diǎn):

  • 都會(huì)移動(dòng)
  • 每次移動(dòng)都是一樣的距離
  • 碰到障礙(墻、對(duì)方)都過不去,需要繞開

當(dāng)然,也存在不同點(diǎn):

  • 角色遇到障礙墻可以打碎開辟道路,怪物不行
  • 角色遇到食物和飲料可以撿起來吃吃喝喝加生命,怪物不行
  • 怪物會(huì)追蹤攻擊角色并扣除角色一定量的生命,角色不能攻擊怪物

根據(jù)上述可以得出結(jié)論,角色和怪物是使用同樣的移動(dòng)邏輯,差別只是在于遇到其他碰撞體的時(shí)候反應(yīng)不同。那么,創(chuàng)建一個(gè)父類MovingObject編寫移動(dòng)邏輯,然后讓角色和怪物的腳本都繼承它,這樣就可以避免同樣的代碼寫兩遍!不同的地方在子類里實(shí)現(xiàn)就可以了~

?(?????)? 子類可以繼承父類的成員并且加以擴(kuò)展,實(shí)現(xiàn)代碼復(fù)用,節(jié)省代碼時(shí)間,并且方便修改。

關(guān)于移動(dòng)邏輯,畫了個(gè)草草的非常簡(jiǎn)單的流程圖如下:


移動(dòng)邏輯

MovingObject只管怎么移動(dòng),不關(guān)心移動(dòng)的請(qǐng)求來自于哪里,所以第一步“獲得移動(dòng)的請(qǐng)求”是子類各自實(shí)現(xiàn)的,比如角色是通過鍵盤方向鍵輸入,而怪物就要看角色是不是已經(jīng)移動(dòng)完畢,畢竟這是個(gè)回合游戲嘛!

右鍵Scripts文件夾,選擇Create->C# Script,創(chuàng)建一個(gè)新的腳本,命名為MovingObject,雙擊打開進(jìn)行編輯。
(多代碼預(yù)警?。?!(;′д`)ゞ)

第1步:自頂向下——AttempMove()

我們創(chuàng)建了一個(gè)方法AttempMove(),代碼如圖:

AttempMove()

AttempMove()要實(shí)現(xiàn)的其實(shí)就是整個(gè)移動(dòng)邏輯:接收方向信息,確定目的地并且判斷該點(diǎn)是否存在障礙物(Move方法),否就平滑移動(dòng)(SmoothMovement方法),是則根據(jù)障礙物類型來執(zhí)行對(duì)應(yīng)操作(OnCantMove方法)。比如移動(dòng)主體是Player的話,判斷如果是Wall則攻擊使之破碎消失。

代碼簡(jiǎn)析:

  • 定義RaycastHit2D類型的變量hit,它將會(huì)作為參數(shù)傳入Move()并且返回,用于存儲(chǔ)線性投射檢測(cè)到的結(jié)構(gòu)體信息(即障礙物)。
  • 調(diào)用Move()進(jìn)行線性投射檢測(cè)和移動(dòng),并把返回的布爾值賦值給canMove(可以移動(dòng)返回true,不能移動(dòng)返回false)。因?yàn)閰?shù)hit使用了修飾符out,所以也會(huì)返回hit變量的值。

一個(gè)函數(shù)只有一個(gè)輸出值,如果想返回多個(gè)值的話就需要加out參數(shù)修飾符。

  • 如果hit變量的transformnull,意味著前方并無障礙,就return退出方法,不再執(zhí)行下面的代碼。
  • 在hit.transform不為null的情況下,獲取hit變量的T組件并且賦值給hitComponent,如果hitComponent不為空則調(diào)用OnCantMove()進(jìn)行相應(yīng)的處理。

舉個(gè)栗子:移動(dòng)主體是Player,在前方有障礙的情況下,獲取障礙的Wall組件,如果的確是有這個(gè)組件證明那就是Wall對(duì)象(障礙墻),那么就可以調(diào)用OnCantMove()去執(zhí)行敲墻操作了!其他情況則維持原樣,被擋住原地不動(dòng)。

根據(jù)上述解析,我們知道應(yīng)該要傳入一個(gè)T參數(shù),代表障礙物身上的某一個(gè)指定的組件的參數(shù)。這個(gè)組件類型不固定,可能是Wall,也可能是Player(假設(shè)移動(dòng)主體是Enemy)。一般函數(shù)的參數(shù)都是指定了類型的,所以這時(shí)候應(yīng)該怎么樣傳T才能讓子類都適用?在這里我們推薦使用泛型方法。

泛型,其實(shí)就是通過把參數(shù)類型化來實(shí)現(xiàn)同一份代碼操作多種數(shù)據(jù)類型。也就是說,當(dāng)我們不確定傳入的參數(shù)是什么類型,并且不同的類型下我們的代碼邏輯是一樣的時(shí)候,就可以使用泛型方法,實(shí)現(xiàn)更為靈活的復(fù)用。

使用泛型格式如代碼所示,方法名后<T>,{之前用where T : 來指定T是屬于什么,比如在這里是屬于組件Component。關(guān)于泛型,感興趣的可以網(wǎng)上搜索了解更多。

  • 要注意,因?yàn)樽宇惱^承之后要進(jìn)行重寫修改,所以在AttempMove()前加了個(gè)修飾符virtual使之變成虛方法。
    此外,在AttempMove()我們又看到了代碼界一個(gè)很重要的好習(xí)慣。

為了代碼的可讀性和美觀,單個(gè)函數(shù)內(nèi)的代碼不要太多行,過多行的情況下建議拆解成其他方法。

第2步:線性投射檢測(cè)和移動(dòng)——Move()

對(duì)目的地進(jìn)行障礙物檢測(cè)和移動(dòng)的邏輯我們放在了Move()里。

Move()
  • 新增公共成員變量blockingLayer,是進(jìn)行線性投射的時(shí)候指定的LayerMask層。在Unity編輯器的Inspector窗口下,Tag右側(cè)的Layer選項(xiàng)里就是不同的LayerMask。
  • 新增私有成員變量boxCollider2D,指腳本所掛載的游戲?qū)ο笊系呐鲎财鹘M件。
  • Start()方法,對(duì)boxCollider2D進(jìn)行初始化賦值。由于子類繼承的時(shí)候會(huì)對(duì)Start()進(jìn)行重寫,因此在方法前增加virtual關(guān)鍵詞。
  • Move()方法,確定起點(diǎn)和終點(diǎn),先關(guān)閉自身的碰撞器,然后調(diào)用Linecast()進(jìn)行線性投射檢測(cè)并且把返回值賦給hit,再把自身碰撞器開起來。如果hit.transform為null,則調(diào)用平滑移動(dòng)函數(shù)SmoothMovement()進(jìn)行移動(dòng)并且返回true,否則返回false。

SmoothMovement()是協(xié)同程序,開啟協(xié)程需要使用StartCoroutine函數(shù)。

  • Linecast()方法,線性投射,是Unity自帶方法。它會(huì)從開始位置到結(jié)束位置做一個(gè)光線投射,如果與指定的Layer mask層的碰撞體交互,就會(huì)返回真和一個(gè)RaycastHit2D結(jié)構(gòu)體信息。這就是為什么之前在制作預(yù)制件的時(shí)候要把Player、Enemy、Wall、OuterWall這四個(gè)的Layer都設(shè)置為同樣的BlockingLayer層了,因?yàn)橛龅剿麄兪遣豢梢苿?dòng)的,那么就需要Linecast()來檢測(cè)前方是否存在處于BlockingLayer層的碰撞體。

因?yàn)楣饩€從中心點(diǎn)發(fā)射出去的時(shí)候會(huì)碰到自身的碰撞器,所以需要把自身的碰撞器先關(guān)掉,檢測(cè)完了再開啟。

第3步:協(xié)同程序平滑移動(dòng)——SmoothMovement()

物體的移動(dòng)一般是平滑的過程,不是瞬移。而在Unity里,實(shí)現(xiàn)平滑移動(dòng)比較好的方式就是使用協(xié)同程序。

協(xié)程是分步驟執(zhí)行代碼的程序,遇到條件(yield return語(yǔ)句)會(huì)掛起暫停退出,直到條件滿足才會(huì)被喚醒繼續(xù)執(zhí)行后面的代碼。

使用協(xié)同程序的方法:聲明一個(gè)返回值為IEnumrator的方法,然后在方法中使用yield return語(yǔ)法返回,在需要用協(xié)程的地方(比如上面Move方法末尾)通過StartCorutine方法去調(diào)用。

SmoothMovement()

簡(jiǎn)單說明下:

  • 新增公共成員變量moveTime,每次移動(dòng)耗時(shí),單位是s。
  • 新增私有成員變量inverseMoveTime,在Start()賦值為moveTime的倒數(shù)。官方說法是乘法比除法更有效率(不懂這個(gè)說法),我倒是覺得這個(gè)變量應(yīng)該指的是速度。因?yàn)槊看我苿?dòng)的距離是1,那么根據(jù)速度=距離/時(shí)間,inverseMoveTime是速度沒跑了。
  • 新增一個(gè)私有成員變量rb2D,剛體組件,并在Start()進(jìn)行初始化賦值。
  • SmoothMovement()方法,使用sqrMagnitude來返回起點(diǎn)和終點(diǎn)的距離的平方并且賦值給sqrRemainDistance

由于后面要拿來和最小浮點(diǎn)值float.Epsilon進(jìn)行比較,在程序里面模長(zhǎng)平分的計(jì)算成本比數(shù)量級(jí)要低。

  • 在while循環(huán)里,當(dāng)sqrRemainDistance的值大于float.Epsilon,也就是說距離大于0,就會(huì)進(jìn)入循環(huán)進(jìn)行移動(dòng)。先調(diào)用MoveTowards()計(jì)算出下一次移動(dòng)的目標(biāo)位置newPosition(在當(dāng)前地點(diǎn)和終點(diǎn)的連線上),再調(diào)用MovePosition()來移動(dòng)剛體到newPosition。由于移動(dòng)之后位置變了,所以重新計(jì)算了當(dāng)前地點(diǎn)和終點(diǎn)的距離平方,并進(jìn)入下一次循環(huán)。
  • yield return null,表示剩余代碼將在下一幀繼續(xù)執(zhí)行。也就是說代碼每次進(jìn)入while循環(huán)讀到y(tǒng)ield return null之后會(huì)暫停執(zhí)行,下一幀再回來進(jìn)行下一次循環(huán)。

暫停執(zhí)行的時(shí)候程序會(huì)把移動(dòng)的結(jié)果展示到屏幕,所以我們就可以看到物體平滑的移動(dòng),而不是while循環(huán)直接跑完了,我們只看到最終的結(jié)果,就是瞬移到終點(diǎn)。

第4步:抽象方法——OnCantMove()

這個(gè)方法在父類里特別簡(jiǎn)單。真的特別簡(jiǎn)單。

OnCantMove()

因?yàn)椴恍枰唧w實(shí)現(xiàn)!hhhh媽呀前面好多代碼啊,看到這個(gè)方法好感動(dòng)o(╥﹏╥)o

  • OnCantMove()方法前面添加關(guān)鍵詞abstract之后,它就變成了一個(gè)抽象方法,不需要具體實(shí)現(xiàn)。因?yàn)檫@個(gè)方法要實(shí)現(xiàn)的代碼邏輯是,當(dāng)不能移動(dòng)并且障礙物是可互動(dòng)的對(duì)象的時(shí)候要進(jìn)行的操作。而每個(gè)子類都是不一樣的處理方式,因此我們把具體的實(shí)現(xiàn)內(nèi)容交給子類去添加。
  • 因?yàn)閭魅氲膮?shù)類型不固定,因此OnCantMove()也是使用泛型參數(shù)方式。

emmm,MovingObject類基本編寫完畢。為什么說基本呢?切回到Unity編輯器,控制臺(tái)非常友好地報(bào)了一個(gè)錯(cuò)誤。

抽象方法報(bào)錯(cuò)

這是因?yàn)橛谐橄蠓椒ǖ念愂浅橄箢悾枰陬惷懊嬗?strong>abstract關(guān)鍵詞進(jìn)行修飾。

抽象類

二、創(chuàng)建可被破壞的墻——Wall Script

要想角色遇到Wall的時(shí)候能夠擊打敲碎開辟路線,需要Wall本身掛有一個(gè)腳本組件以便認(rèn)定從而調(diào)用OnCantMove()。那么我們就來編寫一個(gè)Wall腳本吧?。ㄗ⒁膺@里Wall是中間隨機(jī)生成的障礙墻,并非周圍那一圈OuterWall)

第1步:編寫Wall Script

右鍵Scripts文件夾,選擇Create->C# Script,創(chuàng)建一個(gè)新的腳本,命名為Wall,雙擊打開進(jìn)行編輯。

Wall Script
  • 新增兩個(gè)公共成員變量,dmgSprite是被攻擊一次之后的Wall圖片,hp是Wall的生命/血量。

訪問限制為public的類成員,可在Unity編輯器的Inspector窗口設(shè)置和更改屬性值。

  • 新增私有成員變量spriteRenderer,在函數(shù)Awake()里進(jìn)行初始化賦值,是游戲?qū)ο骔all上掛載的Sprite Renderer組件。
  • Start()改成Awake(),因?yàn)锳wake()是在游戲?qū)ο笊芍罅⒖陶{(diào)用,不管是否enabled,而且Awake()調(diào)用在Start()之前。因此為了安全,官方也是推薦把初始化操作放在Awake()里。
  • DamageWall(),執(zhí)行Wall被破壞之后的處理。把自身的圖片換成dmgSprite(表示攻擊有效),hp扣除loss,如果hp小于等于0則隱藏Wall(并且Wall上的碰撞器等組件都關(guān)閉),在玩家看來就是墻被打碎了,并且可以移動(dòng)過去了。

第2步:掛載設(shè)置Wall Script

腳本寫好之后要掛載在游戲?qū)ο笊喜拍苌А;氐経nity編輯器,點(diǎn)擊Assets內(nèi)的Prefabs文件夾,同時(shí)選擇Wall1-Wall8,點(diǎn)擊最上方菜單欄的Component-Scripts-Wall,把Wall腳本都添加到Wall預(yù)制件。

批量添加腳本到預(yù)制件

可以看到現(xiàn)在每個(gè)Wall預(yù)制件右側(cè)的Inspector窗口都多了個(gè)Wall腳本組件。

Wall組件

在上面可以自由設(shè)定Wall的生命值hp。現(xiàn)在我們需要點(diǎn)擊Dmg Sprite選項(xiàng)右側(cè)的小圓圈,打開Sprite選擇頁(yè)面,為每個(gè)Wall預(yù)制件選擇一個(gè)被攻擊時(shí)候的Sprite!

Wall1的DmgSprite

按順序選擇就好,不過官方只給了7張圖,所以咱們Wall5和Wall6都選擇了編號(hào)52的那張圖。

三、讓角色先走起來——Player Script

完成MovingObject類只是第一步,還需要Player和Enemy分別繼承它并且擴(kuò)展才能真正的讓角色和怪物移動(dòng)起來。我們首先想要實(shí)現(xiàn)的是角色的移動(dòng),因此先創(chuàng)建一個(gè)Script,命名為Player,雙擊打開編輯。

第1步:獲取輸入進(jìn)行基本移動(dòng)

在Player腳本里,我們第一時(shí)間要做的是獲取外部的移動(dòng)請(qǐng)求,然后才能調(diào)用AttempMove方法去進(jìn)行移動(dòng)。

獲取輸入
  • Player類必須繼承MovingObject類,所以冒號(hào)后面記得修改為MovingObject。
  • 因?yàn)橐獙?shí)時(shí)不停的接收移動(dòng)請(qǐng)求輸入,因此我們把相關(guān)代碼放在了Update()里。Update()是在每次渲染新的一幀的時(shí)候會(huì)調(diào)用。
  • 在Update()里,定義了int類型變量horizontal、vertical,代表移動(dòng)方向向量。先調(diào)用GetAxisRaw()獲取原始軸坐標(biāo)值并且分別賦值給horizontal、vertical,然后設(shè)定當(dāng)horizontal不為0的時(shí)候,vertical強(qiáng)制性為0,也就是說不能斜著走,只能上下左右移動(dòng)。最后判斷,當(dāng)horizontal和vertical任何一個(gè)值不為0,就調(diào)用AttempMove<Wall>()進(jìn)行移動(dòng)。
  • 為了能看腳本不報(bào)錯(cuò)從而讓角色移動(dòng)起來,我們把OnCantMove()這個(gè)抽象方法也先寫上,代碼空著以后補(bǔ)上。

經(jīng)歷上述一大堆的代碼和操作,我們終于可以嘗試著去讓大胡子移動(dòng)起來了。切回到Unity編輯器,把Prefabs文件夾的Player預(yù)制件拖到Hierarchy窗口生成對(duì)象實(shí)例,然后把Player Script添加到Player對(duì)象上。

Player腳本組件

Blocking Layer選擇BlockingLayer,然后運(yùn)行游戲,再按下鍵盤的方向鍵操縱大胡子移動(dòng),看看我們辛苦的成果吧!

最初的旅行

啊咧,為何和我們想象中的不大一樣?這就是傳說中的買家秀嗎?!!!∑(?Д?ノ)ノ
大家會(huì)發(fā)現(xiàn)大胡子的確可以動(dòng)起來了,碰到Wall、Enemy、OuterWall也會(huì)被擋住,但是存在好幾個(gè)問題。

  1. 并不是按一下就走一格,而是比一格還遠(yuǎn),而且每次還不一樣的距離。
  2. 碰到食物、Wall、Exit、Enemy都沒有相應(yīng)的效果。(還未實(shí)現(xiàn))
  3. 在大胡子走動(dòng)一次之后,理應(yīng)是Enemy的回合,但是它們傻傻站在原地不動(dòng)。(還未實(shí)現(xiàn))

第2和3是因?yàn)槲覀冞€沒編寫相關(guān)的邏輯代碼。而第1點(diǎn),或許已經(jīng)有聰明的同學(xué)想到是什么原因了。提示一下,和Update()這個(gè)方法的特點(diǎn)有關(guān)系!仔細(xì)想想~~~

思考中

———建—議—思—考—下—再—看—答—案———

前面提到,Update()是在每次渲染新的一幀的時(shí)候會(huì)調(diào)用!在我們的金貴的小手指按下方向鍵到起來的這短短不到1S的時(shí)間內(nèi),游戲已經(jīng)渲染了好幾幀,也就是調(diào)用了好幾次Update(),獲取了好幾次的移動(dòng)請(qǐng)求輸入!因此雖然我們只按了一次方向鍵,游戲里的大胡子卻移動(dòng)了好幾次,跑的老遠(yuǎn)。那么,我們要怎么做才可以達(dá)到我們想要的效果,就是按一次方向鍵執(zhí)行一次Update()走動(dòng)一格呢?
對(duì)這個(gè)回合游戲來說,角色的移動(dòng)是和怪物的移動(dòng)息息相關(guān)的。角色移動(dòng)兩次之后就轉(zhuǎn)變成是怪物回合,每一只怪物都移動(dòng)完畢了又會(huì)轉(zhuǎn)回角色回合,然后一直進(jìn)行這個(gè)循環(huán)。
也就是說,現(xiàn)在沒有怪物移動(dòng)邏輯代碼,因此沒辦法切換到怪物回合,而我們暫時(shí)也不打算現(xiàn)在就轉(zhuǎn)去編寫Enemy的移動(dòng)代碼,所以接下來我們將用一個(gè)取巧的辦法來解決這個(gè)問題,后續(xù)做了Enemy的移動(dòng)之后會(huì)把這些再修正。

第2步:臨時(shí)修正同時(shí)獲取多次輸入

現(xiàn)在的問題是在角色還沒移動(dòng)完畢到位的時(shí)候,程序又通過Update()獲取了新的輸入請(qǐng)求,導(dǎo)致角色在半路又決定走多一格。那我們是否可以人為設(shè)置一個(gè)開關(guān),在角色開始移動(dòng)的時(shí)候把開關(guān)關(guān)掉,這期間不能獲得新的輸入,角色移動(dòng)完畢再把開關(guān)開起來,這時(shí)候才能獲得新的移動(dòng)輸入請(qǐng)求?讓我們?cè)囋囘@個(gè)辦法。
首先,在GameController腳本里添加起開關(guān)作用的變量playerTurn,布爾值,初始值為true。因?yàn)橐谄渌_本調(diào)用所以訪問限制為public,但是不希望在Unity編輯器可以進(jìn)行改動(dòng),所以用[HideInInspector]隱藏公有變量。

playerTurn

然后我們?cè)赑layer腳本的Update()里添加如下代碼:

增加獲取輸入的條件

if語(yǔ)句是判斷當(dāng)playerTurn為false的時(shí)候return返回,不執(zhí)行后續(xù)代碼獲取輸入。然后橫線處是確定了有實(shí)際移動(dòng)輸入請(qǐng)求的時(shí)候把playerTurn改成false,這樣就不會(huì)在移動(dòng)期間又進(jìn)入U(xiǎn)pdate()里面獲取輸入。
移動(dòng)期間把開關(guān)關(guān)了,那么移動(dòng)完畢了要把開關(guān)開起來,不然就沒法進(jìn)行下次移動(dòng)。所以我們?cè)贛ovingObject腳本的SmoothMovement()AttempMove()都添加了以下代碼:

修改MovingObject

為什么同一句代碼需要在兩個(gè)地方都添加?這是因?yàn)槊看我苿?dòng)的時(shí)候有兩種情況,可移動(dòng)和不可移動(dòng)。無論是哪種情況,都需要把playerTurn重新改回true,以便獲取下一次的移動(dòng)請(qǐng)求。
好了,這時(shí)候我們保存腳本,回到Unity編輯器運(yùn)行游戲。

正常移動(dòng)

成功!可以看到,移動(dòng)一次的距離是剛好一個(gè)格子了。
然后我們還有好多沒實(shí)現(xiàn),如撿東西吃、開路、被敵人砍、進(jìn)入下一關(guān)等等。我寫這些很慢(擔(dān)心講不清所以老修改),就讓我們?cè)谙乱黄僖姲桑?/p>

上一章傳送門:生成關(guān)卡
下一章傳送門:角色移動(dòng)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容