暗黑破坏神词缀实现思路2.0
代码示例
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的问题,我考虑了三种方式:
- 使用枚举,如EComp::Attr
- 使用字符串, 如 "Attr"
- 使用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依赖编译器,不确定是否有些情况会有问题。
我总结了下原则:
在跨系统模块中,或者是动态生成的东西,使用字符串作为参数更加灵活和方便,其他情况使用枚举保证方便维护和合作
属性模块
如结构图示:
- 有一个属性组件
AttrComp
挂载在Entity
上,管理了一堆属性Attr
Attr
可以接收Binder
绑定器和Modifier
修改器。当Modifier
进来会重新收集所有Modifier
的数据并计算,并通知Binder
。需要说明的是:- 在我的设计中
Attr
没有所谓的默认值,如果角色天生带有一些基础属性,则由角色/职业相关组件添加Modifier
进来 Binder
的思想是观察者模式,Binder是在观察者的回调函数上进一步的封装,以减少重复的逻辑。比如多个面板有属性数值显示,就可以把获取属性数值,赋值给UI控件封装成一个Binder在多个面板上复用,只需传入控件和属性类型。也可以传入lambda表达式作为一般的回调使用,如这里的AttrBinderLambda
。注意Binder在刚绑定时也会触发回调。
- 在我的设计中
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;
词缀效果应用:
- 你的攻击力:增加10(fix)/ 增加150%(more)/ 总增50%(total)
- 你的攻击力为0,你的防御力为上升原本攻击力的150%
这里2应用Binder
和Modifier
的实现:
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值。