数值模型 战斗属性和判定公式

战斗属性

战斗属性属于战斗单位的简单数据字段, 提供基础的数据读写操作(相对于magic state而言).
在实践中 一般战斗属性还包含从配置文件中load数据, 一级战斗属性到二级战斗属性的封装, 以及按照功能,战斗单位层级的划分等.

战斗属性的设计和实现

字段表设计

字段表在设计上隶属于’接口层’这个概念, 简单直接, 易于访问和同步处理时目的.

在C++中 通常考虑用一块静态数组的内存来表示

A A A A A A A A A
字段名 HP MP ATK CRIT RESIST CRIT HIT RATE MISS RATE
转换类型 INT INT INT INT INT FLOAT FLOAT

这种字段表的设计有以下优点和特性:

  • 性能非常高
  • 可以直接操作,遍历内存 方便统一管理
  • 以数组下标完成ID的设计, 并使用BIT位进行脏数据标记辅助进行增量同步
  • 初始化约定为0, 进行初始同步时只同步非0数据即可, 后续采用增量同步, 减少同步量
  • 字段表中的数据修改可以由属性表进行托管结算 也可以直接set
    • 通常战斗属性均为属性表托管结算和刷新 不能直接set
    • 通常能量条类属性直接进行set
    • 复杂的属性一般由多个属性功能完成 例如
      • 血量上限 (战斗属性)
      • 当前血量 (属于能量条类, 则特殊条件下以’血量上限属性’为依据进行set 例如出生满血)

属性表设计

属性表是一个三维数组:

  • 第一维数组的下标对应字段表的下标, 即ID一一对应完成属性表和字段表的映射关系
  • 第二维数组对应具体属性层级, 层级按照功能划分, 例如基础数值层, 模块专属修饰层
  • 第三位数组对应具体属性具体层级下的所有项, 例如基础值, 万分比加和修正, 万分比连乘修正, 值修正等

通常面板属性遵循基础的计算公式:
$$实际值=基础值*系数+修正值$$
然后根据具体的数值需求进行细化和扩展等

可能的一种计算通用计算公式(能满足大多数属性计算的公式), 提供了系数加和修正 系数连乘修正 值修正:

1
2
3
4
5
6
7
基础系数加和修正 = 基础层.基础值*(1+Σ(基础系数修正))

基础系数连乘修正=基础系数加和修正 * power(2, Σ(基础系数连乘修正指数))

基础值值修正=基础系数连乘修正+Σ(BASE VALUE + VALUE)

总值系数加和修正 连乘修正 总值修正 ...

表如下:

A BASE VALUE BASE RATE BASE EXP VALUE TOTAL RATE TOTAL EXP TOTAL VAL
BASE LAYER —- —- —- —- —- —- -
ITEM LAYER —- —- —- —- —- —- -
SKILL LAYER —- —- —- —- —- —- -

功能设计:

  • 面板属性指的是通过属性表计算的字段, 该类字段对外关闭修改权限, 只能通过修改对应的属性表中的项来进行修改. 外部只读.
  • 字段表中的字段值不参与计算 值作为属性表的导出数据, 因此字段可以是float类型, 但是属性表中的项必须全是整数以保证属性在对称修改中时钟保证结果的确定性和正确性(精度满足需求即可)
  • 需要保证每个项的值不会发生溢出, 来保证属性的对称修改过程中的正确性和可靠性.

对称计算的确定性和一致性保证

战斗面板属性一个很重要的特征就是在战斗对抗中会发生持续性的对称修改, 例如加攻击力 减速的BUFF等等.
而为了简化设计提高性能, 不能在每次战斗判定流程中遍历所有buff来完成属性计算, 常见的做法中, 我们会先修改属性(加/乘) 然后持续一段时间后再进行恢复(减/除).

例如: 加攻击力100持续1秒 减速40%持续3秒 减速30%持续2秒

然后问题就来了, 假如说我们其中一个项纪录的是float的值修正, 比如防御力+3, 然后来了一个超级防御(无敌的一种)+850万防御 然后再对称减去后, 其结果我们期望是0 但实际上可能为-1

问题原因

计算机的浮点数通常并不满足结合律和分配率, 这就会带来计算结果的不确定性.

结合律应该满足:

1
(a + b) + c == a + (b + c)

举例来说: 假如上述公式的单位类型是浮点数, 其中a和b是小数, c是大数, a+b的和在c的最后一位精度上就可能会出现
1
2
(a + b) + c > b;  
a + (b + c) == b;

单独的a和b都小于c的精度, 但a和b的和正好落在a+b的和在c的精度范围内可以被累加, 但是单独的a,b如果不在c的精度范围内累加的结果不会发生变化

造成浮点计算的不确定性原因:

  • 因顺序不同带来的浮点变动导致计算结果不同
  • 因为精度损失并且这种损失未进行良好定义而导致的结果不同
    • 不同位宽的FPU的计算会导致最终输出的最后一个有效位的数字不同
    • 不同的浮点算法也会导致精度的不同
    • 小数部分在二进制表达上存储的精度丢失问题 (例如0.1的除不尽带来的轧差处理)
    • 精度末尾的舍入策略

解决方法

  • 基础策略
    • 用整数处理
    • 用定义良好的定点数处理
      • 一个简单的实现可能是10000 或者 2^N的大小 但是要保证放大倍数在有效的精度范围内并且不会溢出
    • 隔离浮点数, 一旦整数或者定点数转换为浮点数后, 就不应该参与原有需要保证重复计算一致性的流程
    • 三角函数可以通过查表或者泰勒级数求值
  • 实践方案
    • 能用整数的尽量用整数
    • 范围比较小的浮点数通过缩放为整数进行计算和存储 并保证不会(定义好)溢出
    • 对业务进行拆解, 把无法避免的浮点计算部分改成单向导出模式, 保证计算不会出现回溯影响.
    • 使用能保证计算确定性的浮点/定点数学库

使用Modifer完成对属性表中的项进行修改 并实现常见公式

Modifier的核心是一个对称操作, 即在开始时进行修改操作, 在结束后保证复原, 为了保证这一点我们会采用整数存储所有项, 技术出来的面板属性值不会参与计算本身, 并通过对项的合理拆分与划分完成高阶的系数加和与系数连乘等公式 如下:

  • 对属性进行加和修正

    • 直接对加和项进行加和, 修改器结束后减去加上的数字即可.
  • 对属性进行系数加和修正

    • 抽出来独立的项保存系数的分子(分布可定位万分或者百分等), 在计算结果时使用该项的和带入分母后进行乘法修正:
      • BUFF A加攻击力30 %
      • BUFF B加攻击力40 %
      • BUFF A 和 B同时加攻击力是 攻击力 * (1 + 30 % + 40 %) = 攻击力 * 170%
      • BUFF A B生效后先移除A 则在项里减去30并重新计算: 攻击力 * (1+40%) = 攻击力 * 140%
  • 对属性进行系数连乘修正

    • 抽出来独立的项进行累加该系数换算成的指数信息, 指数相加等于系数连乘
      1. 对于要修正的系数取其对数, 然后放大到满足需求的精度位数后 保存为整数.
      2. 使用时, 先把结果带入分母取得连乘的积, 然后用之前计算的底进行指数计算 即可得到连乘结果
      3. 对称恢复时候, 拿到的仍然是系数, 所以需要进行对数计算后得到保存时的数字, 这里需要保证一致性.
    • 例:
      • BUFF A 减速20 %
      • BUFF B 减速40 %
      • BUFF A B同时生效, 结果为 基础移动 * (1-20%) * (1-40%) == > 基础移动 * 48%
      • 这里通常不用加和是因为需要一个平滑的收益递减的减速曲线而不是直接减到0 .
        • 例如两个减速50%用加法公式则减速到0 但是连乘公式则为基础的25%.

通常, (十进制描述) 游戏中配置的比例修饰的精度为 万分之一, 有效位数4个即够用, float的有效位为23 * log10(2) 约等于 6.9个十进制位

一级二级属性

一级属性的结果不直接参与战斗, 一级属性的结果通过换算公式成为不同二级属性中的项
例如力量按比例加攻击力 加防御值

  • 一级属性换算二级属性的公式应当保证确定性
  • 在修改一级属性的任意项后 都应该用原有的结果进行反向恢复二级属性中的项 然后重新计算结果并带入二级属性进行重刷

非战斗属性字段

这里指的是能量条类, 或者程序记录的某些key id 等 都可以使用字段表, 但是这些字段的来源是动态逻辑并非属性表, 因此这部分字段是不进行属性导出和刷新的

数值模型

一般来说游戏的数值分为上游和下游两大块 下游主要跟战斗系统有关 是上游的基础

  • 上游数值模型
    • 成长线规划
    • 怪物数值
    • 产出和消耗
    • 商业化
  • 下游数值模型
    • 伤害公式
    • 战斗力公式
    • 职业和属性
    • 属性量化和拆解

伤害公式

一般有减法,乘法, 除法等公式
根据这几种公式的效益和多人表现
一般来说多人游戏类似MMO MOBA会采用乘法或者除法来规避不破防问题
人数少对数值比较敏感对抗比较激烈的可能会采用减法公式

  • 减法公式
    $$damage = attack - defence$$

    • 等比缩放攻击力和防御力战斗结果不变
    • 攻击力边际收益递减, 防御力边际收益递增, 容易出现不破防
      • 假设一个单位输出是10点伤害 受到10点伤害
        • 攻击力伤害加成每点分别是1/10, 1/11 … 1/19
        • 防御加成每点分别是1/10 , 1/9, …. 1/1
      • 同等战力下攻防每一点变化都会带来极大影响
      • 战斗力可能不传递 例如低攻高速不破高防, 但是打低防可以获得大量输出, 但是高防不一定能打过低防, 低防攻击高破防更多的情况下.
        • 虽然可以做一些循环克制, 但是群战时, 一个不破防可以秒大批低战力玩家.
        • 防御价值太高
  • 乘法公式
    $$damage = attack * (1-f(defence)); $$
    一般情况下
    $$f(defence) = defence / (C + defence) $$

    • 战斗力可以单独计算 hp*(1-f(defence))* attack
    • 攻击和免伤的效益都是边际递减, 没有减法的不破防问题
    • 数值变化不明显
    • 引入暴击值暴击率来修饰边际递减和数值不敏感问题
    • 战力传递
  • 除法公式
    模型如下 但一般不会直接用, 因为线性投放伤害不变:
    $$ damage = attack/ defence $$
    变种版本 方差版本, 伤害值可能大于攻击力 一般也不用:
    $$ damage = attack* attack / defence $$
    一般用的比较多问题比较少的版本:
    $$ damage = attack * attack / (defence + attack)$$

    • 攻击价值递增 防御价值递减
    • 没有不破防的问题
    • 数值变化不敏感

攻击判定流程:瀑布判定和圆桌判定


一般来说一个战斗单位会有多个进攻属性以及对应的多个防御属性形成多组判定和计算, 其中攻击判定指的是命中的类型分支判定:
例如说通常攻击有 命中率 暴击率 , 防御有闪避率, 格挡, 招架等, 分支有 MISS, 闪避, 招架, 格挡, 暴击, 普通, 这些分支区分优先级, 例如闪避判定肯定优于暴击判定

面板数据举例:

攻击方 受击方 面板期望 瀑布算法 事件概率 圆桌算法 事件概率
命中率 90% 90% 90% 90%
闪避率 20% 18.00% 72.00% 18.00% 72.00% 18.00%
招架 15% 13.500% 61.200% 10.800% 58.500% 13.500%
格挡 30% 27.00% 42.840% 18.3600% 31.500% 27.000%
暴击率 25% 22.500% 32.1300% 10.7100% 9.0000% 22.5000%
普通命中 9% 32.1300% 9.0000%
瀑布算法和圆桌算法的核心共同点都是对对抗组进行优先级划分, 并且高优先级在前 如果所有分支均为发生, 那么就是普通命中.

区别在于瀑布判定会逐个分支判定, 因此越靠后的分支其发生概率的衰减越大, 但是都能生效

圆桌算法则是按照面板属性去逐个排好, 超过桌子大小的事件概率将会被截断和裁剪掉, 如果圆桌如果能放下 则和面板期望概率一致, 如果放不下 则已放入桌子的仍然能做到和面板期望一致, 但是被裁剪掉的讲不会有机会发生.

再举个例子来说:

假设: 命中率是90%, 闪避率是50%, 招架是40%, 格挡是30%.
以命中率为桌面重新计算分支概率则是
命中率90% * 闪避50% = 45%
命中率90% * 招架40% = 36%
命中率90% * 格挡30% = 27%

因此桌面依次是

闪避 招架 格挡 暴击 普通命中
45% 36% 19% 0% 0%

判定伪代码:

1
2
3
4
5
6
7
8
local r = rand()%100  
if r < 闪避 then
--闪避
elseif r < 闪避+招架 then
--招架
elseif elseif r < 闪避+招架+暴击 then
--暴击
end