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

2D Roguelike 最終效果

前言

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

  • 基本移動邏輯
  • 角色獲取輸入進行移動

本節你將學會什么?

  • 認識父類(基類)/子類(派生類)/抽象類/抽象方法/虛方法
  • 認識泛型函數
  • 如何通過協程進行平滑移動
  • 如何利用線性投射Linecast()檢測碰撞
  • 如何獲取輸入并且進行移動

一、實現基本移動邏輯——編寫父類MovingObject

最終的游戲效果我們可以看到,大胡子和怪物雖然種族樣貌均不同,但是它們在移動這塊存在很多共同點:

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

當然,也存在不同點:

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

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

?(?????)? 子類可以繼承父類的成員并且加以擴展,實現代碼復用,節省代碼時間,并且方便修改。

關于移動邏輯,畫了個草草的非常簡單的流程圖如下:


移動邏輯

MovingObject只管怎么移動,不關心移動的請求來自于哪里,所以第一步“獲得移動的請求”是子類各自實現的,比如角色是通過鍵盤方向鍵輸入,而怪物就要看角色是不是已經移動完畢,畢竟這是個回合游戲嘛!

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

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

我們創建了一個方法AttempMove(),代碼如圖:

AttempMove()

AttempMove()要實現的其實就是整個移動邏輯:接收方向信息,確定目的地并且判斷該點是否存在障礙物(Move方法),否就平滑移動(SmoothMovement方法),是則根據障礙物類型來執行對應操作(OnCantMove方法)。比如移動主體是Player的話,判斷如果是Wall則攻擊使之破碎消失。

代碼簡析:

  • 定義RaycastHit2D類型的變量hit,它將會作為參數傳入Move()并且返回,用于存儲線性投射檢測到的結構體信息(即障礙物)。
  • 調用Move()進行線性投射檢測和移動,并把返回的布爾值賦值給canMove(可以移動返回true,不能移動返回false)。因為參數hit使用了修飾符out,所以也會返回hit變量的值。

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

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

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

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

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

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

  • 要注意,因為子類繼承之后要進行重寫修改,所以在AttempMove()前加了個修飾符virtual使之變成虛方法。
    此外,在AttempMove()我們又看到了代碼界一個很重要的好習慣。

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

第2步:線性投射檢測和移動——Move()

對目的地進行障礙物檢測和移動的邏輯我們放在了Move()里。

Move()
  • 新增公共成員變量blockingLayer,是進行線性投射的時候指定的LayerMask層。在Unity編輯器的Inspector窗口下,Tag右側的Layer選項里就是不同的LayerMask。
  • 新增私有成員變量boxCollider2D,指腳本所掛載的游戲對象上的碰撞器組件。
  • Start()方法,對boxCollider2D進行初始化賦值。由于子類繼承的時候會對Start()進行重寫,因此在方法前增加virtual關鍵詞。
  • Move()方法,確定起點和終點,先關閉自身的碰撞器,然后調用Linecast()進行線性投射檢測并且把返回值賦給hit,再把自身碰撞器開起來。如果hit.transform為null,則調用平滑移動函數SmoothMovement()進行移動并且返回true,否則返回false。

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

  • Linecast()方法,線性投射,是Unity自帶方法。它會從開始位置到結束位置做一個光線投射,如果與指定的Layer mask層的碰撞體交互,就會返回真和一個RaycastHit2D結構體信息。這就是為什么之前在制作預制件的時候要把Player、Enemy、Wall、OuterWall這四個的Layer都設置為同樣的BlockingLayer層了,因為遇到他們是不可移動的,那么就需要Linecast()來檢測前方是否存在處于BlockingLayer層的碰撞體。

因為光線從中心點發射出去的時候會碰到自身的碰撞器,所以需要把自身的碰撞器先關掉,檢測完了再開啟。

第3步:協同程序平滑移動——SmoothMovement()

物體的移動一般是平滑的過程,不是瞬移。而在Unity里,實現平滑移動比較好的方式就是使用協同程序。

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

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

SmoothMovement()

簡單說明下:

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

由于后面要拿來和最小浮點值float.Epsilon進行比較,在程序里面模長平分的計算成本比數量級要低。

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

暫停執行的時候程序會把移動的結果展示到屏幕,所以我們就可以看到物體平滑的移動,而不是while循環直接跑完了,我們只看到最終的結果,就是瞬移到終點。

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

這個方法在父類里特別簡單。真的特別簡單。

OnCantMove()

因為不需要具體實現!hhhh媽呀前面好多代碼啊,看到這個方法好感動o(╥﹏╥)o

  • OnCantMove()方法前面添加關鍵詞abstract之后,它就變成了一個抽象方法,不需要具體實現。因為這個方法要實現的代碼邏輯是,當不能移動并且障礙物是可互動的對象的時候要進行的操作。而每個子類都是不一樣的處理方式,因此我們把具體的實現內容交給子類去添加。
  • 因為傳入的參數類型不固定,因此OnCantMove()也是使用泛型參數方式。

emmm,MovingObject類基本編寫完畢。為什么說基本呢?切回到Unity編輯器,控制臺非常友好地報了一個錯誤。

抽象方法報錯

這是因為有抽象方法的類是抽象類,需要在類名前面用abstract關鍵詞進行修飾。

抽象類

二、創建可被破壞的墻——Wall Script

要想角色遇到Wall的時候能夠擊打敲碎開辟路線,需要Wall本身掛有一個腳本組件以便認定從而調用OnCantMove()。那么我們就來編寫一個Wall腳本吧!(注意這里Wall是中間隨機生成的障礙墻,并非周圍那一圈OuterWall)

第1步:編寫Wall Script

右鍵Scripts文件夾,選擇Create->C# Script,創建一個新的腳本,命名為Wall,雙擊打開進行編輯。

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

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

  • 新增私有成員變量spriteRenderer,在函數Awake()里進行初始化賦值,是游戲對象Wall上掛載的Sprite Renderer組件。
  • Start()改成Awake(),因為Awake()是在游戲對象生成之后立刻調用,不管是否enabled,而且Awake()調用在Start()之前。因此為了安全,官方也是推薦把初始化操作放在Awake()里。
  • DamageWall(),執行Wall被破壞之后的處理。把自身的圖片換成dmgSprite(表示攻擊有效),hp扣除loss,如果hp小于等于0則隱藏Wall(并且Wall上的碰撞器等組件都關閉),在玩家看來就是墻被打碎了,并且可以移動過去了。

第2步:掛載設置Wall Script

腳本寫好之后要掛載在游戲對象上才能生效。回到Unity編輯器,點擊Assets內的Prefabs文件夾,同時選擇Wall1-Wall8,點擊最上方菜單欄的Component-Scripts-Wall,把Wall腳本都添加到Wall預制件。

批量添加腳本到預制件

可以看到現在每個Wall預制件右側的Inspector窗口都多了個Wall腳本組件。

Wall組件

在上面可以自由設定Wall的生命值hp。現在我們需要點擊Dmg Sprite選項右側的小圓圈,打開Sprite選擇頁面,為每個Wall預制件選擇一個被攻擊時候的Sprite!

Wall1的DmgSprite

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

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

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

第1步:獲取輸入進行基本移動

在Player腳本里,我們第一時間要做的是獲取外部的移動請求,然后才能調用AttempMove方法去進行移動。

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

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

Player腳本組件

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

最初的旅行

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

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

第2和3是因為我們還沒編寫相關的邏輯代碼。而第1點,或許已經有聰明的同學想到是什么原因了。提示一下,和Update()這個方法的特點有關系!仔細想想~~~

思考中

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

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

第2步:臨時修正同時獲取多次輸入

現在的問題是在角色還沒移動完畢到位的時候,程序又通過Update()獲取了新的輸入請求,導致角色在半路又決定走多一格。那我們是否可以人為設置一個開關,在角色開始移動的時候把開關關掉,這期間不能獲得新的輸入,角色移動完畢再把開關開起來,這時候才能獲得新的移動輸入請求?讓我們試試這個辦法。
首先,在GameController腳本里添加起開關作用的變量playerTurn,布爾值,初始值為true。因為要在其他腳本調用所以訪問限制為public,但是不希望在Unity編輯器可以進行改動,所以用[HideInInspector]隱藏公有變量。

playerTurn

然后我們在Player腳本的Update()里添加如下代碼:

增加獲取輸入的條件

if語句是判斷當playerTurn為false的時候return返回,不執行后續代碼獲取輸入。然后橫線處是確定了有實際移動輸入請求的時候把playerTurn改成false,這樣就不會在移動期間又進入Update()里面獲取輸入。
移動期間把開關關了,那么移動完畢了要把開關開起來,不然就沒法進行下次移動。所以我們在MovingObject腳本的SmoothMovement()AttempMove()都添加了以下代碼:

修改MovingObject

為什么同一句代碼需要在兩個地方都添加?這是因為每次移動的時候有兩種情況,可移動和不可移動。無論是哪種情況,都需要把playerTurn重新改回true,以便獲取下一次的移動請求。
好了,這時候我們保存腳本,回到Unity編輯器運行游戲。

正常移動

成功!可以看到,移動一次的距離是剛好一個格子了。
然后我們還有好多沒實現,如撿東西吃、開路、被敵人砍、進入下一關等等。我寫這些很慢(擔心講不清所以老修改),就讓我們在下一篇再見吧!

上一章傳送門:生成關卡
下一章傳送門:角色移動

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,283評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,947評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,094評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,485評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,268評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,817評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,906評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,039評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,551評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,502評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,662評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,188評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,907評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,304評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,563評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,255評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,637評論 2 370

推薦閱讀更多精彩內容