Unity《ATD》塔防RPG類3D游戲架構設計(一)


《ATD》 游戲簡介


游戲類型:塔防+RPG的3D游戲

游戲要素:3D 塔防 英雄 建築樹 搭配

主體玩法:游戲里將會有一波波怪物進攻基地。玩家可以建造塔來防御敵人,同時也可以控制單獨的個體英雄角色來攻擊敵人。

游戲模式

  • 第三人稱視角的RPG模式

  • 上帝視角的建造模式

控制方式:在游戲中使用Tab按鍵,切換這兩種操作模式:

  • RPG模式下:WASD控制移動,Space跳躍,鼠標左鍵普通攻擊。
  • 建造模式下:鼠標左鍵建造,E銷毀已建造的建築。
  • 數字鍵1,2,3,4,5,6控制物品欄,對應英雄技能或者建築安放。

勝利條件:消滅所有敵人 或者 堅持到時間結束

失敗條件:基地生命值為0 或者 英雄死亡

《ATD》 整體結構


一般來說,整個Unity游戲項目整體結構,我比較偏向分為如下5部分:

  • 場景對象 :不會產生互動的可視物體對象,例如地型/建築/燈光。

  • 游戲對象 :參與互動的游戲對象,例如英雄/怪物/塔。

  • 游戲邏輯 :負責控制游戲的邏輯,其邏輯對象一般是單例的。

  • 非游戲性對象 :負責增強游戲效果,但不是直接的游戲邏輯,例如UI/HUD/特效/聲音。

  • 工具 :負責輔助編碼,例如日志工具,調試工具。

在《ATD》游戲項目里,我是這樣設置游戲對象目錄的:

注:“個體”在《ATD》里的術語表示游戲對象單位。

《ATD》 游戲機制


通過分析《ATD》策划案,確立了兩種需要實現的基本游戲機制:

Buff機制

和策划商量后,策划制作了下面一張含所有Buff屬性的Excel表:

由於策划還沒想好Buff名字,直接套用裝備或者技能名字來命名Buff。

首先,使用了一個數據類型BuffData,用於完全映射Buff在表格的所有屬性:

public class BuffData
{
    public int ID;
    public string Name;
    public int HpChange;              //血量變化
    public double HpChange_p;         //血量百分比變化
    public int AttackChange;          //攻擊力變化
    public double AttackChange_p;     //攻擊力百分比變化
    public double AttSpeedChange_p;   //攻擊速度百分比變化
    public double SpeedChange_p;      //速度百分比變化
    public int HpReturnChange;        //血量恢復數值
    public double HpReturnChange_p;   //血量百分比恢復數值
    public int AddReviveCount;        //增加復活次數
    public bool isDecelerate;         //減速
    public bool isVertigo;            //眩暈
    public bool isParalysis;          //麻痹
    //...等屬性
}

然后我們就可以用一個 List<BuffData>來存儲表示所有Buff種類。
為了讀取Excel表,並根據讀入的所有Buff種類屬性來初始化 List<BuffData>,於是就引入了一個BuffDataBase的全局單例類來負責此事:

//全局單例類
public class BuffDataBase : MonoBehaviour
{
    //讀取excel插件生成的json文件
    public TextAsset buffDataJson;
    //存儲BuffData的列表
    private List<BuffData> buffDatas;
    
    //全局單例實現
    //...
    
    //根據ID獲取相應的BuffData對象
    public BuffData GetBuffData(int ID){
      //...
    }
}

為了表示游戲對象得到/失去一個Buff而從BuffDataBase找到並拷貝一份BuffData對象/釋放掉一份BuffData對象顯然是不明智的。(BuffData所占空間大,開銷大)
正確的做法應該是使用索引/引用的方式,例如某個游戲對象持有3號索引,則表示它當前受一個ID為3的Buff影響。

為了引入Buff的時間有效性,則進一步封裝索引,於是編寫了下面一個Buff類:

public class Buff
{
    public int ID;              //BuffData的ID(索引)
    
    public double time;         //持續時間
    public int repeatCount;     //重復次數
    public bool isTrigger;      //是否觸發類型
}

因為每個Buff的時間有效性都有所不同:有些Buff是一次性觸發Buff;也有一些是持續性Buff,持續N秒;還有一些是被動buff,永久生效。

所以我這里就總結了個規則,Buff主要分為兩種類型:

  • 持續型(Non-Trigger):開始對屬性造成生效影響一次,有效時間結束時造成失效影響一次。例如一段時間內增加攻速Buff
  • 觸發性型(Trigger):有效時間內,每一幀對屬性造成生效影響一次。例如一次性傷害Buff,光環Buff。

然后Buff的有效時間取決於2個屬性:

  • 持續時間(time):每幀持續時間減少DeltaTime
  • 觸發次數(repeatCount):每幀觸發次數減一

當一個Buff對象,持續時間 <= 0 並且 觸發次數為0,則應視為失效。特殊地,觸發次數為-1時,表示無限時間。

這樣Buff/BuffData/BuffDataBase基本構造就出現了:
整個游戲同種類Buff只用存儲一份BuffData;但是可以有很多個對象持有索引/引用,指向這個BuffData。
游戲對象持有Buff對象,通過BuffDataBase訪問BuffData的數據,然后利用這些數據對游戲對象屬性造成影響。

看到這里,可能會有人想到前面有個問題:對於任意一種Buff,它往往有很多屬性是false或者0,使用這種完全映射會不會很影響空間占用或者效率。

  1. 首先,空間占用絕對不用擔心,因為前面BuffDataBase機制保證同種Buff只有唯一BuffData副本,其所有BuffData總共占用量不過幾kb而已。
  2. 其次,至於效率,例如說某個Buff對某個游戲對象造成影響,因為是完全映射,所以需要對該游戲對象每個屬性都要進行更新,其實這也並不是太糟糕。
    而且只要游戲對象有比較好的Buff計算方式,可以讓一個Buff對象的整個有效周期只對對象造成兩次影響計算(生效影響,失效影響),避免每幀出現影響多余的計算,這樣就很不錯了。

Skill機制(技能機制)

可以說技能是我比較頭疼的部分。
看到那千奇百怪的Skill需求時,然后才總結出大概這幾個分類:

  • 主動Buff技能 = 主動釋放,生成一個Buff
  • 被動Buff技能 = 初始化時,生成一個Buff
  • 召喚技能 = 生成一個游戲對象
  • 指向性技能 = 主動釋放,對鎖定的目標生成一個Buff

最后我決定使用繼承接口的方式來實現Skill:

技能接口:

public interface ISkill
{
    // 技能初始化接口
    void InitSkill(Individual user);
    // 使用技能接口
    void ReleaseSkill(Individual user);
    /// 技能每幀更新
    void UpdateSkill(Individual user);
    
    /// 技能是否冷卻
    bool IsColdTimeEnd();
    // 技能冷卻百分比
    float GetColdTimePercent();
}

需要注意的一點是,技能並不是主動釋放時調用一個自定義的技能函數即可完事:
例如持續性的范圍技能,需要每幀調用散發Buff的函數。
所以一個ISkill對象 該有這3種重要的接口方法:初始化/主動釋放/每幀更新

下面是其中一個派生類的具體實現:

由於一開始設計考慮不足,Buff技能類暫時包含了ActiveBuff技能類和PassiveBuff技能類的功能。

// 示例:Buff技能類
public class BuffSkill : ISkill
{
    public int buffID;               //目的Buff
    public bool isAura = true;       //光環
    public bool releasable = true;   //是否主動釋放
    public float range = 0.01f;      //范圍

    private float coldTime = 5.0f;  //冷卻時間
    private float timer = 5.0f;     //冷卻計時

    //構造方法
    public BuffSkill(int buffID,bool releasable = true,bool isAura = true, float range = 0.01f)
    {
        this.buffID = buffID;
        this.isAura = isAura;
        this.range = range;
        this.releasable = releasable;
    }
    
    //初始化技能
    public void InitSkill(Individual master)
    {
        //非光環的被動buff
        if (!releasable && !isAura)
        {
            var individual = master.GetComponent<Individual>();
            master.GetComponent<MessageSystem>().SendMessage(2, individual.ID,buffID);
        }
    }
    
    //釋放技能
    public void ReleaseSkill(Individual master)
    {
        //主動buff
        if (releasable && IsColdTimeEnd())
        {
            timer = 0.0f;

            Factory.TraversalIndividualsInCircle(
                (individual) => { master.GetComponent<MessageSystem>().SendMessage(2, individual.ID, buffID); }
                , master.transform.position, range);
        }
    }
    
    //技能每幀更新
    public void UpdateSkill(Individual master)
    {
        //增加計時
        timer =Mathf.Min(timer+Time.deltaTime, coldTime);
        
        //光環被動buff:每幀向周圍range范圍內的對象散發buff
        if (!releasable && isAura)
        {
            Factory.TraversalIndividualsInCircle(
                (individual) => { master.GetComponent<MessageSystem>().SendMessage(2, individual.ID, buffID); }
                , master.transform.position, range);
        }
    }
    
    //得到冷卻時間百分比
    public float GetColdTimePercent()
    {
        if (!releasable) return 1.0f;

        return timer / coldTime;
    }

    //冷卻時間是否結束
    public bool IsColdTimeEnd()
    {
        return timer > coldTime;
    }
}

派生類的構造函數很重要,這樣即使硬編碼了4個技能派生類,通過不同的數據參數傳入,也能產生更多不同的技能對象。

最后還應該再寫一個SkillDataBase全局單例類,它負責讀取策划寫的技能配置文件,來初始化出來一些ISkill對象,以供游戲對象使用。

不過項目代碼還沒寫完,因此項目目前是直接在SkillDataBase的初始化函數直接硬編碼3個技能。


public class SkillDataBase : MonoBehaviour
{
    //技能json文件
    public TextAsset skillDataJson;
    
    //全局單例實現
    //...
    
    //讀取文件,初始化ISkill對象給英雄使用
    public void InitSkillsData(){
      //...TODO:讀取文件來初始化英雄的技能
      
      //目前硬編碼給英雄賦予3個技能
      HeroSkills.Add(new BuffSkill(6, true, true, 5.0f));   //主動技能:嘲諷Buff
      HeroSkills.Add(new BuffSkill(0, false, false));       //被動技能:回血buff
      HeroSkills.Add(new BuffSkill(14, true, false));       //主動技能:攻速戒指buff
    }
}

以后的話,SkillDataBase的初始化函數應該是讀取某種配置文件,然后生成若干個對應的技能對象分配給游戲對象使用:

仇恨機制

待更新

《ATD》 游戲模型


策划案部分摘取



分析了策划案后,顯而易見里面划分了這4種游戲模型:
英雄怪物陷阱

繼承實現游戲模型

最初想到的是使用繼承的方式來實現這些游戲模型(如圖):

然而考慮到現在的英雄/怪物/陷阱/塔類型已經足夠太多了,而且以后還可能會擴展更多。
若用繼承的方式,其派生類數量將到達一個小團隊難以維護的地步。

至於之前設計Skill機制的時候,為什么反而采用繼承的方式,原因如下:

  1. 策划案里,Skill的種類只有8種,所以需要編寫的派生類比較少,而英雄/怪物/陷阱/塔所有種類總共加起來有二十多種。
  2. Skill不是GameObject,沒有Unity提供的GameObject-Component機制,不太方便接納組件(除非自己再實現一套組件模式)。
  3. 實際上,還有個設計Skill的思路就是把Skill設計成一個行為樹,通過組合節點來生成一個Skill。然而因為當時急於實現,於是拋棄了這個想法。

首先為了統一術語,避免游戲模型和Unity的GameObject弄混淆,我們定義了一個稱之為 個體(Individual) 的名詞,來表示一個游戲模型單位。

組合實現游戲模型

再想到Unity的GameObject-Component機制,於是最后我采用組合組件的方式來設計這幾個游戲模型。

那么如何表示一個個體游戲對象呢?
首先我們需要編寫一些個體游戲對象必要的組件腳本類。

對於一個個體游戲對象,它可能由如下圖構成:

一般來說行為和輸入都應該放在一起統稱為控制器,然而實際上在游戲里,輸入來源可能是玩家,也可能是AI,因此把個體對象行為和輸入分離是個好的選擇。

也就是說它得有屬性,行為,操控行為的輸入,還得可以容納Buff機制,Skill機制和裝備機制。

根據這些需求分化出來不少組件類:

然后為了解耦各組件的依賴關系,特別是跨游戲對象的組件依賴,於是還額外引入了一個 消息系統組件 ,實際上就是用於實現觀察者模式。
每個個體對象都必須帶一個消息系統組件,且其他編寫的組件類基本上都依賴這個消息系統組件。

例如,A個體用指向性技能對B個體進行釋放實際上的行為是:
由A個體的 技能系統組件 發送消息給A個體的 消息系統組件
然后A個體的 消息系統組件 把消息再轉發給B個體的 消息系統組件
B個體的 消息系統組件 再把消息通知給 Buff系統組件 ,從而讓B個體受到該Buff影響。

組合實現設計結構

最終個體游戲對象的組件依賴關系圖:


然后通過一個GameObject然后添加好模型,然后放置一些組件從而組合出來一個個體游戲對象。

一個怪物個體游戲對象示例:

結語


《ATD》本來只是社團部門內提出的一個Unity游戲項目,而我負責這個項目的程序架構設計。
然而中途開發因為不少事,我們不得不放棄了這個項目。
感覺到有些可惜,因此才想得寫點東西總結一下開發這個項目時的經驗。

GitHub - ima-games/ATD: Unity RPG+塔防3D游戲

第二篇:Unity《ATD》塔防RPG類3D游戲架構設計(二) - KillerAery - 博客園


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM