• 暗黑破坏神词缀实现思路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  //注意头文件
    
    struct ClassName
    {
    	template <typename Ty>
    	static string Get()
    	{
    		static string name = typeid(Ty).name();
    		return name;
    	}
    };
    
    /*获取组件*/
    template<class T>
    std::shared_ptr Entity::GetComp()
    {
    	string name = ClassName::Get();
    	
    	return std::dynamic_pointer_cast(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(func);
    }
    
    void AttrModifyIncByAttr::Apply(const SP(Entity)& in_ent)
    {
    	if (in_ent)
    	{
    		auto comp = in_ent->GetComp(EComp::Attr);
    		if (comp)
    		{
    			comp->AddBinder(from, bind);
    		}
    	}
    	else
    	{
    		if (auto lock = ent.lock())
    		{
    			auto comp = lock->GetComp(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(GetCompTy());
    		if (comp)
    		{
    			comp->UpdMod(target);
    		}
    	}
    }
    

    可以看到:这里在初始化时,创建了一个Binder,在回调时根据攻击力(from)计算修饰的值,SetVal时必要时会通知防御力属性(target)更新属性。
    即:攻击力变化->修饰值变化->防御力变化。
    诸如其他的属性词缀如一半的闪避值转化成攻击力,同理。
    (注意这里防止转化之间的嵌套,比如攻击上升防御的一半,防御又上升攻击的一半,需要根据需求防止循环)

    这里的设计主要是考虑复杂的需求和灵活:比如以后有什么获取所有装备提供的攻击力等需求可以快速的拓展。当然如果属性系统没有那么多花样,这里虽然能满足需求,但是在代码复杂度和效率上可能会差一些。

    其他系统

    多数的情况下,修改器都是更新数据(如属性、状态、标志位等),联动到更新这些数据对应的业务,也有一些是在后续的逻辑中查询这些数据(如战斗系统查询追伤标记位(有可能是某个buf)追加伤害)

    状态系统
    在我的设计中,状态系统管理的多数是Bool值,如:

    • 是否可以行动?
    • 是否可以释放技能?
    • 是否能够移动?

    这些值往往使用乘法运算规则,如原本是可以行动,有个眩晕和封印技能同时添加状态修改器,即val = 1 * 0 * 0 = 0,值为0不能行动。

    当然也有一些其他情况(如标记层数、中毒等)使用数字(Number)

    战斗系统
    在我的设计中,战斗系统和状态、属性系统是紧密关联的。
    战斗系统会频繁的查改属性和状态。战斗系统主要负责战斗的流程处理和结算,并调用其他系统进行状态变更和表现处理。如调用伤害计算公式结算伤害,并修改属性系统HP值。

  • 相关阅读:
    GB/T28181流媒体相关协议详解
    在JavaScript中实现用户输入一个个位数字,自动帮其补两个0,2位补一个,三位不补
    吃货联盟订餐系统(用对象和数组来写的)
    【深度学习实战(25)】搭建训练框架之ModelEMA
    CNN学习笔记
    一本书读懂大数据 读书笔记(1)
    qt creator 设置 项目依赖关系
    【SNMP】snmp trap 与介绍、安装、命令以及Trap的发送与接收java实现
    网易Airtest全新推出:小型便携式集群解决方案!
    自学(网络安全)黑客——高效学习2024
  • 原文地址:https://www.cnblogs.com/hggzhang/p/17626724.html