暗黑破壞神詞綴實現思路2.0

2023-09-04 12:00:34

程式碼範例

Github地址:暗黑破壞神詞綴實現思路-範例程式碼

序言

暗黑類遊戲非常經典,之前玩過很多,也嘗試過寫過實現的思路
最近又在之前的思路下有了新的想法。

我們先來分析下該型別遊戲的特點和其詞綴機制:

暗黑類遊戲

我玩過的暗黑類遊戲主要有:暗黑破壞神,火炬之光,流放之路。我認為暗黑類遊戲的最突出的特點,就是各種各樣的詞綴,讓玩家刷刷刷,按照自己的策略刷出合適的詞綴搭配和提升其數值,從而獲得割草和挑戰更高數值怪物的快感。

詞綴

詞綴按照我的理解就是修飾器,它可以修飾(或覆蓋)原本的各種機制(屬性,技能,狀態...),下面我們舉幾個有趣的例子:

  • 屬性類:
    • 你的防禦力為0,你的攻擊力上升原本防禦力的1.5倍
    • 你的防禦力上升攻擊力的10%
    • 你的火焰抗性等於冰冷抗性
    • 你的50%火焰攻擊力轉換成閃電攻擊力
    • ...
  • 機制計算類:
    • 戰鬥機制:
      • 你不會被暴擊
      • 你的傷害是幸運的(比如傷害是20-40,取值時靠近40的概率增加)
      • 你有50%概率避免中毒
      • 你受到的火焰傷害50%使用冰冷抗性抵抗
      • ...
    • 技能/Buff機制:
      • 施加冰緩時,若已被冰緩則施加冰凍
      • 對標記的目標造成額外傷害
      • 你的攻擊技能有5%概率追加釋放【虛空之雨】
      • ...
    • 地圖機制:
      • 你在地圖中受到【時空鎖鏈】詛咒
      • 地圖中包含一個額外寶箱
      • 你在地圖中獲得的金幣翻倍
      • 地圖中有【墮落的叛徒·烏崔德】
      • ...
    • 其他機制:
      • 你不能裝備武器,你的攻擊力翻倍
      • 你獲得主動技能【貓之勢】
      • 你可以選擇其他職業的一個技能
      • 你從裝備中獲取的屬性提升50%,但你只能裝備被【腐化】的裝備
      • ...

可以看到,詞綴五花八門。有些詞綴非屬性型別的詞綴比如(不會被暴擊/50%避免中毒)也是可以通過屬性或者狀態來實現,但有些還需要其他機制處理(如標記追加傷害,需要在戰鬥模組進行處理)。

在暗黑類的眾多詞綴中,其中很多都是關聯屬性和狀態的,而狀態和屬性在我的實現中比較像(後面會提到),所以這裡詳細說下我對屬性模組和其修改器的實現思路,一些思想會應用於其他模組,並會簡要的提出其他模組可能會不同的地方

我使用c++語言進行實現,其實思想都是一樣的,使用lua/python等在編碼效率等方面會更好些。

需求分析

從上述中,詞綴影響到的機制非常的多。在實現時,可以選擇更加靈活的語言(lua/python等)進行實現。設定方面,配表+指令碼(一般使用配表,一些複雜的效果必要時呼叫指令碼)是可行的,如果編寫編輯器的話可能會更好一些(當然,程式側的開發維護成本會增加,但如果遊戲內容多的話,總體成本應當是下降的)。

結構示意圖


Entity下掛載了一組Comp元件,包含屬性、狀態等。裝備、Buff等掛載一組Affix詞綴,詞綴又包含了一組修改器Modifier(可能有屬性、狀態、甚至是外貌、動作等修改器),修改器在應用的時候作用到各個元件的業務中(比如,屬性修改器作用的屬性元件的屬性範例中,如增加攻擊力)。若是使用觀察者模式,則類似圖中AttrBinder。外面把Binder註冊進來,當屬性變化時主動通知各個Binder屬性變化

EC模組

在角色相關的系統中,EC模式(Entity-Component)是比較常見且好用的,它把(這裡是角色,但是Entity不僅限是角色)Entity的各個業務拆分開來,降低程式碼的複雜度和耦合性。

這裡有一個使用什麼作為存component的key的問題,我考慮了三種方式:

  1. 使用列舉,如EComp::Attr
  2. 使用字串, 如 "Attr"
  3. 使用RTTI(執行時型別資訊 Run-time Type Information)生成的類的名字資訊的字串 typeid(Ty).name()

使用RTTI類名字串

/*取類名String*/
#include <typeinfo> //注意標頭檔案

struct ClassName
{
	template <typename Ty>
	static string Get()
	{
		static string name = typeid(Ty).name();
		return name;
	}
};

/*獲取元件*/
template<class T>
std::shared_ptr<T> Entity::GetComp<T>()
{
	string name = ClassName::Get<T>();
	
	return std::dynamic_pointer_cast<T>(comp_map[name])
}

三種方式對比分析
2比較方便,程式碼量較少,但1更加規範尤其是多人合作專案推薦使用方式1。
方式3同2一樣方便(在c++上其實比2更加方便),不像2那樣容易出錯(有程式碼檢查和提示),但是不像列舉那樣羅列了所有元件型別,且RTTI依賴編譯器,不確定是否有些情況會有問題。
我總結了下原則:
在跨系統模組中,或者是動態生成的東西,使用字串作為引數更加靈活和方便,其他情況使用列舉保證方便維護和合作

屬性模組

如結構圖示:

  1. 有一個屬性元件AttrComp掛載在Entity上,管理了一堆屬性Attr
  2. Attr可以接收Binder繫結器和Modifier修改器。當Modifier進來會重新收集所有Modifier的資料並計算,並通知Binder。需要說明的是:
    • 在我的設計中Attr沒有所謂的預設值,如果角色天生帶有一些基礎屬性,則由角色/職業相關元件新增Modifier進來
    • Binder的思想是觀察者模式,Binder是在觀察者的回撥函數上進一步的封裝,以減少重複的邏輯。比如多個面板有屬性數值顯示,就可以把獲取屬性數值,賦值給UI控制元件封裝成一個Binder在多個面板上覆用,只需傳入控制元件和屬性型別。也可以傳入lambda表示式作為一般的回撥使用,如這裡的AttrBinderLambda。注意Binder在剛繫結時也會觸發回撥
  3. Affix詞綴包含了多個Modifier,在Apply函數中應用到Entitt的各個模組中,如屬性應用到AttrComp指定型別的屬性Attr

應用範例
AttrData範例:

struct AttrData
{
	int fix = 0;
	int more = 0;
	int total = 0;
	int pct = 0;
	int override = 0;
	bool bOverride = false;
	int final = 0;
};

int raw = fix * (1 + more) * (1 + total) + (1 + pct);
int final = bOverride ? override : raw;

詞綴效果應用:

  1. 你的攻擊力:增加10(fix)/ 增加150%(more)/ 總增50%(total)
  2. 你的攻擊力為0,你的防禦力為上升原本攻擊力的150%
    這裡2應用BinderModifier的實現:
int AttrUtil::GetRawOverride(const AttrData& data)
{
    int tmp = GetRawPct(data);
    tmp *= (1 + data.pct / 100.f);
    return tmp;
}

int AttrUtil::GetRawPct(const AttrData& data)
{
    int tmp = 0;
    tmp += data.fix;
    tmp *= (1 + data.more / 100.f);
    tmp *= (1 + data.total / 100.f);
    return tmp;
}

void AttrModifyIncByAttr::Modify(AttrData& data)
{
	data.fix += v;
}

void AttrModifyIncByAttr::Init()
{
	auto func = [this](const AttrData& data)
	{
		if (target == from)
			return;
		int tmp = (AttrUtil::GetRawOverride(data)) * (pct / 100.f);
		SetVal(tmp);
	};

	bind = std::make_shared<AttrBinderLambda>(func);
}

void AttrModifyIncByAttr::Apply(const SP(Entity)& in_ent)
{
	if (in_ent)
	{
		auto comp = in_ent->GetComp<AttrComp>(EComp::Attr);
		if (comp)
		{
			comp->AddBinder(from, bind);
		}
	}
	else
	{
		if (auto lock = ent.lock())
		{
			auto comp = lock->GetComp<AttrComp>(EComp::Attr);
			if (comp)
			{
				comp->RemBinder(from, bind);
			}
		}
	}
	AttrModify::Apply(in_ent);
}

void AttrModify::SetVal(int in)
{
	if (v == in)
	return;
	v = in;
	Upd();
}

void AttrModify::Upd()
{
	if (auto lock = ent.lock())
	{
		auto comp = lock->GetComp<AttrComp>(GetCompTy());
		if (comp)
		{
			comp->UpdMod(target);
		}
	}
}

可以看到:這裡在初始化時,建立了一個Binder,在回撥時根據攻擊力(from)計算修飾的值,SetVal時必要時會通知防禦力屬性(target)更新屬性。
即:攻擊力變化->修飾值變化->防禦力變化。
諸如其他的屬性詞綴如一半的閃避值轉化成攻擊力,同理。
(注意這裡防止轉化之間的巢狀,比如攻擊上升防禦的一半,防禦又上升攻擊的一半,需要根據需求防止迴圈)

這裡的設計主要是考慮複雜的需求和靈活:比如以後有什麼獲取所有裝備提供的攻擊力等需求可以快速的拓展。當然如果屬性系統沒有那麼多花樣,這裡雖然能滿足需求,但是在程式碼複雜度和效率上可能會差一些。

其他系統

多數的情況下,修改器都是更新資料(如屬性、狀態、標誌位等),聯動到更新這些資料對應的業務,也有一些是在後續的邏輯中查詢這些資料(如戰鬥系統查詢追傷標記位(有可能是某個buf)追加傷害)

狀態系統
在我的設計中,狀態系統管理的多數是Bool值,如:

  • 是否可以行動?
  • 是否可以釋放技能?
  • 是否能夠移動?

這些值往往使用乘法運算規則,如原本是可以行動,有個眩暈和封印技能同時新增狀態修改器,即val = 1 * 0 * 0 = 0,值為0不能行動。

當然也有一些其他情況(如標記層數、中毒等)使用數位(Number)

戰鬥系統
在我的設計中,戰鬥系統和狀態、屬性系統是緊密關聯的。
戰鬥系統會頻繁的查改屬性和狀態。戰鬥系統主要負責戰鬥的流程處理和結算,並呼叫其他系統進行狀態變更和表現處理。如呼叫傷害計算公式結算傷害,並修改屬性系統HP值。