暗黑破坏神词缀实现思路2.0

代码示例

Github地址:暗黑破坏神词缀实现思路-示例代码

序言

暗黑类游戏非常经典,之前玩过很多,也尝试过写过实现的思路
最近又在之前的思路下有了新的想法。

我们先来分析下该类型游戏的特点和其词缀机制:

暗黑类游戏

我玩过的暗黑类游戏主要有:暗黑破坏神,火炬之光,流放之路。我认为暗黑类游戏的最突出的特点,就是各种各样的词缀,让玩家刷刷刷,按照自己的策略刷出合适的词缀搭配和提升其数值,从而获得割草和挑战更高数值怪物的快感。

词缀

词缀按照我的理解就是修饰器,它可以修饰(或覆盖)原本的各种机制(属性,技能,状态...),下面我们举几个有趣的例子:

  • 属性类:
    • 你的防御力为0,你的攻击力上升原本防御力的1.5倍
    • 你的防御力上升攻击力的10%
    • 你的火焰抗性等于冰冷抗性
    • 你的50%火焰攻击力转换成闪电攻击力
    • ...
  • 机制计算类:
    • 战斗机制:
      • 你不会被暴击
      • 你的伤害是幸运的(比如伤害是20-40,取值时靠近40的概率增加)
      • 你有50%概率避免中毒
      • 你受到的火焰伤害50%使用冰冷抗性抵抗
      • ...
    • 技能/Buff机制:
      • 施加冰缓时,若已被冰缓则施加冰冻
      • 对标记的目标造成额外伤害
      • 你的攻击技能有5%概率追加释放【虚空之雨】
      • ...
    • 地图机制:
      • 你在地图中受到【时空锁链】诅咒
      • 地图中包含一个额外宝箱
      • 你在地图中获得的金币翻倍
      • 地图中有【堕落的叛徒·乌崔德】
      • ...
    • 其他机制:
      • 你不能装备武器,你的攻击力翻倍
      • 你获得主动技能【猫之势】
      • 你可以选择其他职业的一个技能
      • 你从装备中获取的属性提升50%,但你只能装备被【腐化】的装备
      • ...

可以看到,词缀五花八门。有些词缀非属性类型的词缀比如(不会被暴击/50%避免中毒)也是可以通过属性或者状态来实现,但有些还需要其他机制处理(如标记追加伤害,需要在战斗模块进行处理)。

在暗黑类的众多词缀中,其中很多都是关联属性和状态的,而状态和属性在我的实现中比较像(后面会提到),所以这里详细说下我对属性模块和其修改器的实现思路,一些思想会应用于其他模块,并会简要的提出其他模块可能会不同的地方

我使用c++语言进行实现,其实思想都是一样的,使用lua/python等在编码效率等方面会更好些。

需求分析

从上述中,词缀影响到的机制非常的多。在实现时,可以选择更加灵活的语言(lua/python等)进行实现。配置方面,配表+脚本(一般使用配表,一些复杂的效果必要时调用脚本)是可行的,如果编写编辑器的话可能会更好一些(当然,程序侧的开发维护成本会增加,但如果游戏内容多的话,总体成本应当是下降的)。

结构示意图

image
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值。

posted @ 2023-09-04 10:45  寡人正在Coding  阅读(591)  评论(1编辑  收藏  举报