一、前言
這次想要做的一個小遊戲,或者說一個小Demo,其實是一個簡單且傳統的戰棋戰鬥場景。初步的設計是:在2D世界裡建立一張由六邊形地塊組成的戰鬥地圖,敵我雙方依據體力在地圖上輪流行動並向對方發動攻擊,先消滅掉所有敵人的一方將獲得勝利。
這一輯將比上一輯的內容更簡單,但完成後會是一個功能較完整且可以玩耍的Demo。
我使用的Unity版本是2018.2.7,但是其實並沒有用到2018的任何新功能。
二、Github地址
Github:github.com/elsong823/H… *可以自己下載原始碼
三、正文
預計將分為以下幾篇:
1、建立戰場 根據預定尺寸生成戰場地圖,並隨機一些障礙物。
2、新增對戰雙方 向戰場中新增作戰單位,作戰單位可被點選,並進行移動。
3、戰場邏輯 作戰雙方按照順序依次行動,可進行移動、攻擊。
4、新增戰場UI 新增可以隨時顯示戰況的Hud、為作戰單位新增血條等。
5、擴充套件作戰單位 豐富作戰單位的型別,新增職業,並加入若干不同型別的技能。
6、擴充套件戰場地圖 豐富戰場地圖,加入地形及道具等元素。
7、規範戰鬥配置 可以通過規範化的資料結構配置戰場、職業、技能、道具等。
一、建立戰場
| 目標 生成一個規定尺寸的戰場,戰場上的格子均為六邊形,並且暫定兩種格子型別:普通格子和障礙格子。
| 在開始之前我做了以下事情 1.建立了新的工程。 2.新建場景並儲存在場景資料夾下。 3.刪除預設建立的平行光,調整場景光照設定,棄用了天空盒等。 4.將相機調整為正交相機。 因為是從零點五開始系列,認為讀者有一定的Unity引擎使用基礎,就不對這些操作進行介紹了。
| 建立戰場地圖 我沒有采用把資料和顯示捆綁在一起的做法,而是將資料和顯示分離。 我的思路是用一個戰鬥建立器來建立戰鬥,一場完整的戰鬥資訊至少應包含地圖及對戰雙方的資訊。而Unity搭建的戰鬥場景則可以理解為一場戰鬥的顯示器。
| 資料部分(主要) 戰鬥資料:地圖尺寸,包含所有格子的二維陣列。 格子資料:格子型別,所在行列,所在空間座標。





因為這裡我只想做一個2D的Demo,因此我們只需要一個SpriteRenderer元件即可。








二、新增戰場地圖功能
| 目標 實現一些後面所需的操作地圖的基礎功能,如: 1、點選戰場後高亮顯示被點中的格子; 2、點選兩個位置實現從A到B的導航並在地圖上顯示路徑; 3、設定一個移動半徑,點中格子後顯示出可移動範圍。
實現後的效果如下圖:



| 點選格子變紅 我採用的方法是: 1、獲取螢幕點選位置的世界座標,並將其轉換到格子的Root節點下; 2、用這個座標推斷出點選格子所在的行、列範圍; 3、遍歷這些推測格子的中心,找到距離最近的格子,即為點中的格子。

| 調整地圖瓦片渲染器的位置 為了方便計算格子的位置,這裡調整了瓦片渲染器的位置,保證它在地塊物件的中心,這樣在設定、獲取、計算座標時,少一步轉換的操作,也更容易被理解。

六邊形格子的路程計算
與四邊形地圖差別不大,六邊形地圖也可理解為先做行移動,再做列移動。但它的差異是:在做行移動的同時,其列也可以在一定範圍發生變化,因為它可以斜著走。


綜上,六邊形地圖下兩格子之間的最近路程可以理解為:從起始位置先縱向移動,如果移動到與目標同行,但是目標格子又不在可到達格子範圍內的話,再橫向移動若干單位即可,如圖:

| 調整資料結構 導航功能交給一個新的工具類:地圖導航器來完成,但是原來地圖中格子資訊是直接儲存在戰鬥資料物件中,現在我將地圖資料從戰鬥資料中拆分出來,便於導航器集中處理資料。



| 根據半徑顯示可移動範圍 預設半徑顯示可移動範圍,也可轉換為在進行行偏移的同時,求列覆蓋的最小和最大值。

在計算偏移區域時,使用了上述提到的計算兩格子之間距離的方法。
三、新增對戰雙方
| 目標 向戰場中新增戰鬥單位,完成簡單的戰鬥迴圈,看起來的樣子是: 1、戰場中的對戰雙方輪流行動,可進行移動、攻擊; 2、攻擊將對敵人造成傷害; 3、沒有生命值的戰鬥單位會被從戰場中移除; 4、當一方被全部消滅時,戰鬥結束。
實現後的效果如下圖:

顯示格子座標 為格子新增Text Mesh Pro元件以顯示格子座標,方便除錯。



增加地圖功能:尋找最近可用格子 指定一個起點和一個終點,返回一個環繞終點的距離起點最近、且可用的格子。這主要是為了戰鬥單位在確定攻擊目標後,需要選擇一個它身邊的格子作為移動的目標格子(目前假定所有戰鬥單位的攻擊半徑都為1)。
這裡選擇了一種比較偷懶的方法,就是將導航位置直接設定在目標單位的身上,如果導航成功,則將到達終點的前一個格子作為目標格子,這樣不僅確定了目標格子,同時還將導航路徑一併算出。

準備工作到此為止,下面開始加入戰鬥單位。
| 新增戰鬥單位 因為是六邊形瓦片地圖組成的戰棋遊戲,因此我將戰鬥單位也表示成六邊形,目前來看兩者的Prefab並沒有什麼差別,通過設定Order值來確保戰鬥單位顯示在地圖格子的上方。


| 戰場與戰鬥資訊 一個戰場就是一場完整的戰鬥。每一個戰場目前都包含三大部分:戰場地圖、對戰雙方以及戰鬥過程。
戰場地圖 地圖在之前的文章中已經做了說明,這裡不再贅述。
對戰雙方 對戰雙方的單位是戰鬥組,這裡用戰鬥組編號區分各組,而不是僅用兩個列舉來簡單表示,是考慮到有很多組同時存在且同時對戰的情況。
真正發生戰鬥的被稱為戰鬥單位,每個戰鬥組由若干戰鬥單位組成。戰鬥資料、戰鬥組與戰鬥單位之間的關係如下圖。

| 戰鬥的流程 戰鬥流程包含了戰鬥的核心邏輯,是戰鬥能正常進行且完成的規則,我們用下圖來描述一場戰鬥的基本流程。


其實,當開始一場自動戰鬥時,戰鬥計算器會瞬時計算完整場戰鬥的過程及結果,但這些結果只是資料,並沒有呈現給玩家。
當我們需要把這場戰鬥呈現出來時,把這份資料傳遞給一個對應的顯示(播放)器即可。就好像後端和前端的分工一樣,一個負責產生資料,一個負責將資料呈現。




| 順序分步呈現資料 我這裡使用協同函式(Coroutine)的巢狀來分步呈現戰鬥過程。





| 分離的意義 走吧,走吧,人總要學著自己長大。
人是這樣,資料也是。
其實直接使用一個繼承與MonoBehaviour的指令碼,把各種需要的資料都裝在裡面,直接掛在Prefab上,然後用一個控制器一邊算一邊呈現給玩家,實現起來非常容易。
但是,考慮到後臺可能有多場戰鬥同時在進行;且後期可以在短時間內進行多場戰鬥、收集資料來做戰鬥數值平衡。將資料分離,讓資料可以自行計算,就變得十分重要了。

四、加入玩家控制
| 目標 分配一個角色給玩家手動操作,每回合玩家有一次行動機會,行動的規則是:可從移動、攻擊、待命中選擇一次操作,當玩家選擇移動後,還可選擇一次攻擊或待命的操作;玩家也可以不進行移動直接選擇攻擊或待命,這也會結束當前行動回合。
實現後的效果如下圖:

| 準備工作 首先,為戰鬥單位設定一個是否為手動操作的標記。

其次,為戰鬥單位設定一個手動操作狀態,標記這個戰鬥單位是否可以移動、攻擊。



| 手動操作的邏輯 大概思路是:戰場維護一個戰鬥單位的行動佇列,每當輪到一個單位行動時,如果這個單位是自動操作的,那麼它會自己決定目標,移動並攻擊,上回我們將資料與渲染進行了分離,因此它只是產生了一次行動資料(Action)。 如果這個單位是手動操作的,那它會產生一個等待手動輸入的行動資料,同時返回等待玩家操作狀態。
當戰場發現自己收到了一個等待玩家操作的狀態,就明白其實剛才的傢伙並沒有生成任何有意義的戰鬥資料,因此它必須通知自己的渲染器(那個用來顯示的模組,上回我們介紹過):嘿,幫我問問他到底想幹嘛?
渲染器通過UI與玩家進行互動,獲得玩家要移動、攻擊或是待命等“有用”的戰鬥資訊後,再回頭通知戰場“這個傢伙已經行動過了”,戰場再讓下一個單位行動。

由於下一回我們才會加入戰場UI,這裡我們先用Unity自帶的GUI代替。

五、新增常用的介面
| 目標 使用Unity自帶的UGUI替換之前的GUI來實現一些常用介面: 1、包含開始戰鬥按鈕及戰鬥結束時提示文字的主介面。 2、玩家手動操作時,輔助選擇移動、攻擊及待命的彈出皮膚。 3、點選地圖、戰鬥單位時,彈出的詳情展示皮膚。
實現後的效果如下圖:



不難看出,當UGUI碰撞上專業的素材後,一個個絕美的介面瞬間躍然屏上。這也給了那些常說“程式設計師不懂美”的傢伙們一記響亮的耳光。
| 關於如何製作介面 這些介面都是用UGUI製作的,並沒有什麼難度,相信上手過UGUI或NGUI的同學,只要碰到精美的紋理貼圖,都能輕鬆完成。

受專案大小及時間所限,這裡會使用一套輕量級的介面管理方式。而在此之前,讓我們先做一些前期準備工作。
| 分配相機 為介面的繪製單獨分配一個相機(介面相機),並調整介面相機和戰場相機上的Clear Flags、CullingMask和Depth設定。

整個介面系統的根畫布(ScreenCanvas),是一個型別為ScreenSpace-Camera的Canvas,它的父節點ScreenUIRoot上有專門渲染介面的介面相機。
| 設定層級 ScreenCanvas下設5個節點,分別對應5個層級:背景層、基礎層、彈出層、頂層和Debug層.

各層的功能為: 背景層:裝飾性的、非功能性的介面。 基礎層:常駐的介面(主介面、角色頭像、快捷操作欄等)。 彈出層:點選後彈出的介面(各功能介面)。 頂層:強制顯示在最上層的介面(Tips介面或走馬燈等)。 Debug層:開發時輔助除錯用。
這些層級從下到上放置,遮擋關係是上層遮擋下層。當然,其順序、層數和名稱可根據實際需求進行調整。
需要注意的是,這5個節點(Transform)並非是必須的,它們只是為了Debug時能更直觀的檢視層級間的關係,真正用於用於區分層級的是Canvas上的SortingLayer和OrderInLayer屬性。


| 介面管理流程
比起程式碼,我覺得還是看圖來的更直觀些。

整個介面管理可以簡單拆解為四個部分:開啟介面、關閉介面、層級重新整理及介面重新整理,下面我們依次介紹。
| 開啟介面

| 關閉介面

這就好比兩性交往中女生犯錯通常當時就會被原諒;男生犯錯通常需要好好表現一段時間才有可能被原諒;而單身狗連犯錯的機會都沒有。
| 層級重新整理

因此,當有介面被開啟或關閉後,介面管理器會從上到下的讓各層重新整理自己的顯示狀態及對螢幕的遮擋狀態,並將這個遮擋狀態向下傳遞,用作後面層級的顯示判斷。
| 介面重新整理 我們知道介面的重新整理和顯示是有代價的,因為它們會對CPU及GPU的效能造成開銷。因此我為每個介面設定了是否遮擋了螢幕、不可見時是否仍然重新整理及Dirty屬性。
如果一個介面遮擋了螢幕,那麼它下面的介面首先應該被“隱藏”以減小渲染的壓力;其次如果不可見的介面沒有被設定為“不可見時仍然重新整理”,則當需要重新整理它時(介面資料發生了變化),也只是被打上Dirty標記,並在下次需要顯示的時候再刷新。
| 介面的生命週期函式 上面流程圖中的藍色部分,是介面的生命週期函式,它們會在適當的時間被介面管理器、層級或介面自身呼叫。 Init:初始化,介面首次被載入後呼叫。 OnPush:介面被顯示前,加入層級時被呼叫。 OnShow:介面被顯示時呼叫。 UpdateView:介面需要被重新整理時呼叫。 OnHide:介面被隱藏時呼叫。 OnPopup:介面被關閉,從層級中移除時呼叫。 OnExit:介面不需要被快取,被Destroy前呼叫。

| 介面的儲存策略 介面被關閉後會根據預設的儲存策略決定去留,這裡定義的儲存策略有以下三種。
自動移除:很少用到的介面,每次關閉時會被直接Destroy掉。

臨時快取區:較為常用的介面,在關閉時我們把它放入一個有深度設定的快取區,這個快取區當收入一個新的介面時,會判斷快取量是否已超過預設深度;如果超過了預設深度,會將最早快取的介面彈出並Destroy掉。

常駐快取區:需要頻繁開關的介面,在關閉時我們會把它們放入一個沒有深度設定(快取個數限制)的快取區。

| 介面配置 我使用ScriptableObject物件作為介面配置的載體。每次建立新的介面時,需要同樣建立一個配置物件,並將兩者進行關聯。介面自身及使用者可以通過讀取這個物件獲取介面的配置資訊。

當然我們可以稍微修改Editor,新增一些輔助工具幫助我們快速生成介面配置檔案。

六、為戰鬥單位新增血條,加入傷害文字特效
| 目標 為戰鬥單位新增血條,加入傷害文字特效。
實現後的效果如下圖:


偷個懶,直接使用SpriteRenderer + TextMesh Pro來完成它。

每次生命值變化時需要更新兩個東西:綠條的長度和生命值。
更新文字很容易,直接設定即可;更新綠條的長度呢?只要調整它在x方向上的縮放比例就行了。

但是需要注意將Sprite的錨點設定為Left,這樣僅調整x方向的縮放比例就能達到目的;如果錨點為Center的話,還需要同時調整Position X。

| 新增傷害文字特效 好吧,再偷個懶,直接用一個TextMesh Pro配合一個Animator即可...

做法很簡單,將文字的動畫直接做在一個Animation Clip中,然後用Animator Controller控制播放它們就行了。

在這裡我做了兩個動畫:一個普通傷害的動畫,和一個暴擊傷害的動畫(後面會用到)。當然,它們之間的顯示效果差別非常大。


後面就非常簡單了,在Animator皮膚中設定動畫播放規則,比如通過不同的Trigger來播放普通傷害特效或暴擊傷害特效,然後再在程式碼中根據傷害型別設定對應的Trigger即可。


七、擴充套件作戰單位
豐富戰鬥元素,加入並實現手動釋放不同型別的技能。 | 目標 加入一些常見、簡單的技能型別,如:
1、單體遠端 對遠端一個敵方單位進行攻擊。

2、單體遠端帶範圍效果 對遠端一個敵方單位進行攻擊,同時對其周圍一定距離內所有敵方單位造成相同傷害。

3、以自身為中心的範圍技能 以自身為中心,對周圍一定距離內所有敵方單位造成傷害。

4、遠端指定範圍的技能 遠端指定攻擊一定範圍內的所有敵方單位。

5、單體恢復 恢復自己或一個友方單位的HP值。

需要提前宣告的是,本文主要記錄的是在手動釋放技能時,操作展示上的一些關鍵事項;技能計算的邏輯請見程式碼;AI釋放不同型別技能也將放在下回。
| 增加效果顯示 我為地塊、戰鬥單位設定了不同的顯示狀態以便更直觀的獲取操作反饋。

無論是地塊還是戰鬥單位,都是通過簡單的狀態機來實現不同顯示效果的切換。
| 地塊的效果顯示 由於地塊會包含多種狀態共存的情況,比如上面的遠端範圍攻擊:某些地塊會被同時設定為技能釋放範圍和技能效果覆蓋範圍。

為了解決這種情況,地塊的顯示狀態判斷使用了位運算。

| 戰鬥單位的效果顯示 戰鬥單位不涉及多種狀態同時存在的情況,處理起來就簡單多了。

| 技能資訊 與之前的介面配置一樣,我仍然使用ScriptableObject作為技能資訊的載體,因為這樣實現起來最快捷。

由於目前包含的技能型別較少,數值計算也十分簡單,因此只需要少量的屬性就足夠了,這裡就不再贅述了。
| 手選技能的操作規則 其實,這些技能的計算邏輯並不複雜,麻煩一些的是不同型別技能在手動操作時的規則及顯示邏輯。 我在這裡制定了簡單的操作規則:
1、對於單體近戰、單體遠端、單體恢復技能: 選擇技能後顯示技能釋放範圍,標出範圍內外的單位;點選可選單位則釋放技能。

2、對於單體遠端帶範圍效果的技能: 選擇技能後顯示技能釋放範圍,標出範圍內外的單位;點選可選單位後展示技能效果範圍,標出範圍內的單位;再次點選該單位後釋放技能。

3、對於以自身為中心的範圍技能: 選擇技能後顯示技能效果範圍,標出範圍內外的單位;點選任意單位後釋放技能。

4、對於遠端指定範圍的技能: 選擇技能後顯示技能釋放範圍;點選範圍內任意地塊,顯示技能效果範圍,標出範圍內外的單位;再次點選相同地塊釋放技能。

對遠端指定範圍技能的操作
5、操作任意型別技能時,點選滑鼠右鍵為取消。
| 簡單的技能分析 為了實現上述操作邏輯,我在點選使用技能後加入了一個簡單的技能分析步驟,它會遍歷場上所有戰鬥單位,並根據釋放者及技能型別將他們劃分為可被選中的、隊伍不符的、距離不符的和狀態異常的四類,這樣後面的操作邏輯實現起來就簡單多了。


| 增加技能操作介面 我微調了戰鬥單位的操作皮膚,為攻擊按鈕增加了一個選擇技能的二級皮膚。新增了一個一級皮膚透明處理的小設定來區分層級;以及在不同螢幕位置點選時的皮膚彈出位置的優化,以防止皮膚彈出到螢幕以外無法操作。由於這不是本次介紹的重點,就不在這贅述了。

八、加入AI系統(上)
建立超級簡單的AI系統。 | 目標 加入一個超級簡單的AI系統,會自動釋放不同型別的傷害技能。

自動釋放技能的AI
需要提前說明的是,建立簡單的AI系統預計將拆分為三篇更新。
第一篇(本篇)通過加入一些簡單的AI邏輯,保證戰鬥單位可以自動選擇(傷害)技能、自動作戰,進而順利的完成一場戰鬥。
第二篇會進一步豐富AI的決策系統,讓它的表現更具期待性,使戰鬥變得更加有趣。
此外,我邀請了我的好友Aillieo,拜託他按照自己的方式也設計一個AI系統。
因此,我會在第三篇介紹他所設計的AI系統,並對這三篇做一個整體的總結。
| 非常簡單的AI系統 個人以為,有意思的AI系統可以簡單的定義為:
讓人覺得符合邏輯,卻又在一定程度上超出了預期。
如何實現一個非常簡單的AI系統呢?為了讓問題變得再簡單些,我將AI的行為拆解成固定的三個步驟: 1、確定攻擊目標; 2、向攻擊目標移動; 3、使用技能。
| 確定攻擊目標 將”合理“的目標設定為攻擊目標,是件並不太容易的事情。
這裡我且不談那些優秀的遊戲是怎麼做的,因為我也不知道。只說說我目前所使用的方法:仇恨系統。

AI使用仇恨列表確定攻擊目標
每當一個戰鬥單位在戰場中被敵人攻擊時,他就會偷偷的在自己的小本本里記下攻擊者的名字,以及他們的罪行。
當輪到他行動時,他就會掏出自己攥了很久的小本本,按照之前它們揍自己的程度進行降序排列,然後按照這個名單,判斷自己反擊的可能性。
這裡,沒有反擊的可能性,指的是:如果目標已經被人包圍,自己卻又是一個近戰角色無法靠近,那他就會嘟囔著“哼饒你一條狗命”,然後繼續看下一個人。
直到確定這個傢伙可以被自己攻擊到,他就會合上小本本,把他的名字刻上自己的心頭,然後準備開始下一個步驟:向他移動。
| 向目標單位移動 向目標移動就很簡單了,通過A-Star演算法找到移動路徑後,行動即可。

但是這裡有一個小問題:應該選擇哪個格子作為移動的終點呢?
特別是當攻擊者是某些遠端攻擊單位,比如遊戲中常見的魔法師或者弓箭手,每次都走到目標旁邊去攻擊,感覺上就有點像“送外賣”。
其實解決方法也很簡單,在導航時仍然選擇目標所在位置做為導航終點,但在距離終點一定距離時,停止導航並返回導航路徑即可。這個停止距離,就是遠端攻擊單位的射程,或者手動設定的某個值。

射程為2的小紅,導航停止在距離小藍兩個單位的格子上
這與“真正的愛情,能跨越一切障礙”是一個道理。
當然,如果這個人兒並不在天邊,而在觸手可及的地方,那他根本就不用移動,直接進入下面的環節吧。
| 對目標使用技能 光說,不練,假把式。
好容易走到了他(她)的身邊,總得有所表示吧?
試想一個場景:你很喜歡一個女孩兒,在表白的關鍵時刻,你有一百種表達方法,但你卻只能選擇一種,究竟哪種才是最有效的呢?
如果是真實的生活,答案很簡單:看運氣。
但是遊戲則不同,你可以用S/L大法(存、讀檔大法)來不斷重試,直到找出效果最好的那一種!
決策將要使用的技能也可以是一樣的。
這裡我且不談那些優秀的遊戲是怎麼做的,因為我也不知道。只說說我目前所使用的方法:簡單的計算所有可用技能的釋放回報。

計算技能釋放得分的公式異常複雜,由於這並不是一篇學術性論文,因此這裡不做詳細的解釋和說明,只把公式列出即可:
技能釋放得分 = 技能造成的總傷害 ÷ 技能消耗的能量值
也就眾所周知的:

天啊,好麻煩。
但是,在得到了按照釋放得分降序排列的可用技能列表後,帶著何種的心情、用著怎樣的姿勢、使用哪個技能的問題,就變得十分容易了。
可能我們只需要注意下遠端範圍技能的釋放點選擇問題即可。

對於遠端範圍技能,我們當然可以使用一些方法,找到覆蓋最多目標的釋放點。
但為了省事兒,我這裡是這麼處理的:當目標超過技能釋放距離時,嘗試找到釋放技能時,可以覆蓋到目標單位的點,然後從這裡隨便選一個即可。當然,如果目標本身就在技能釋放半徑內,就選它為釋放中心了。

| 能量值 當然,為了幫助AI計算出哪個技能的釋放得分更高,我為每個戰鬥單位都增加了一個能量值的屬性(你也可以認為它是魔法值);為每個技能增加了釋放的能量消耗;同時還為遊戲增加了每次行動時恢復10個單位能量的設定。但是由於這些邏輯都很簡單,這裡就不贅述了。
最後,我們來回顧下整個行動流程吧: 1、打誰; 2、去哪打; 3、怎麼打。

| 寫在最後 至此,建立超級簡單的AI系統篇就介紹到這了。如你所見,這裡只是實現了非常簡單的AI行動邏輯,並沒有體現出各種型別AI的不同,我們下期將嘗試著解決這個問題。