碧蓝航线的跨射炮弹散布模型【可视化航线第0期(demo)】
iphonecinqs
2019年06月01日 11:44

【可视化航线第0期(demo)】碧蓝航线的跨射炮弹散布模型

作者 (码农): Overflood  (iOS 硫磺岛, bilibili: iphonecinqs​)

前言

作为当前版本的推图主力(大概不需要之一),后排大炮的主炮输出显然直接影响着整支队伍的强度。在相当大的程度上,战列、战巡的主炮输出受到主炮装备面板数值的影响,而其中炮弹散布的影响往往是比较难以量化的。随着一系列降低散布机制的上线(厌战改、98式复读机、乔五),当前碧蓝版本的后排主炮配装需要考虑到散布值降低对输出提升的影响。在这里,我假设了一种二维高斯分布的主炮散布模型,对固定靶进行射击模拟,统计了不同主炮的单轮射击命中数与散布降低值之间的关系。在这个模型下,我们可以得到的基本结论如下:

  1. 对单一固定靶进行主炮射击,紫410mm连装炮的物理命中期望为1.73发/轮,406mm MK6为2.34发/轮,四联装356mm为2.95发/轮。

  2. 在一定范围内,跨射主炮对固定靶的物理命中率和散布降低值可以近似为线性关系,在这一范围内,每减1点散布约相当于提高约4%单轮主炮物理命中数。

  3. 偏离这一线性关系所需的散布减少量与主炮面板散布有关:紫410mm约在散布-4时偏离,MK6约在散布-6时偏离。散布减少量超过这个阈值后,减散布所带来的物理命中率提高的收益降低。

  4. 厌战改在主炮散布减5点并使用MK6时,对固定靶的物理命中期望为2.87发/轮,相对于没有散布降低时提高了22%的物理命中率。

  5. 本模型下,对于炮击值600(面板+装备)的后排战列舰,携带复读机的收益(主炮输出期望+12.9%)低于超重弹(黑弹,+14.4%),这与当前的一些实测(如《九八式延迟射击装置13-4道中对比超重弹弹链的收益测试》​)结论一致。

请注意:

  1. 由于作者并没有看过碧蓝航线的代码,所以本文中的理论模型、结论推论可能与实战数据存在较大差异。这篇文章只是我和舰队里的秃子讨论之后心血来潮写的一份推测,不代表真正的游戏机制,更不代表实际作战。作者欢迎各种类型的模型讨论和实战检测。

  2. 本文使用Jupyter Notebook上的markdown语言写作,代码为Python 3,有条件的读者应该可以执行代码,完成结论重现。由于本文作者所受过的编程培训实在有限,代码写作的规范性可能引起您的不适,请酌情无视。如果不想看代码,直接无视掉程序框内的所有内容即可。

  3. 本文的主炮原始数据来源于wiki上玄虚小圣的《散布机制和穿甲超重弹细节》,同时我也阅读了wiki上炮姐爱喝柠檬汁的《随版本更新的装备解析》,这两篇文章对我的建模有很大的帮助。

1. 建立主炮散布的物理模型

首先,我们先导入以下python模块,以便进行接下来的科学计算和绘图。

import numpy as np

from matplotlib import pyplot as plt

from matplotlib import patches

plt.rcParams.update({'font.size': 18})

plt.rcParams['font.sans-serif'] = ['Adobe Heiti Std'] # 用来正常显示中文

plt.rcParams['axes.unicode_minus'] = False # 用来正常显示负号

接着,让我们来建立主炮的物理模型。在这里,我定义了一个叫Gun()的对象,用来代表各种主炮,它包含了我们需要的主炮面板信息。这其中最主要的信息有:

1. 单轮主炮弹数(Gun.shell_num),比如406mm MK6是3,紫色410mm连装炮是2。

2. 着弹范围( Gun.aoe_area),我将这个量理解为了单颗炮弹的AOE圈半径,换言之,距离靶心小于这一值的炮弹都算物理命中,当然实际命中还要考虑闪避公式。对于绝大部分后排跨射主炮,这一值为15(引用自小圣的表格)。 注意:在这个表格中,我把所有的距离量都理解为了半径,如散布22,我理解成了最大散布圈的半径。统一理解为半径和统一理解为直径的结论是一致的。

3. 散布范围(Gun.dispersion),我的理解是水平方向的炮弹最大散布圈半径。也就是说,瞄准固定靶射击,水平方向着弹点距离靶心距离不会超过这个值。典型地,MK6的散布是22。

4. XZ偏移(Gun.xz_shift),这个值我不是很懂,看了小圣和炮姐的资料后还是很懵。我在这里理解为了竖直方向的炮弹最大散布圈半径。也就是说,炮弹的散布圈是个椭圆,椭圆方程为:

本模型中散布圈的椭圆方程

其中是(x, y)炮弹落点, (x_0, y_0)是瞄准的靶心。任何炮弹的落点不会超过这个椭圆。据舰队里的某位匿名老干tu部zi体感,手机横屏方向的炮弹散布要比竖直方向大,大概说的就是XZ偏移要略小于散布范围。典型地,MK6的XZ偏移是19。

5. Sigma (Gun.sigma),这个值是我随便添加的,目前取1。我们可以假设着弹点的二维分布是高斯的,也就是说,对于单一一颗炮弹,其落点相对于靶心的方位角Θ是完全随机的,落点与靶心的位移r满足高斯分布:

正态分布

其中是P(r)是落点距靶心r的概率,满足归一化条件(从正无穷积分到正无穷为1)。但由于最大散布圈的存在,我们规定不得超过一个上限,在水平方向上这个上限是Gun.dispersion,竖直方向上是Gun.xz_shift,其他方向上用勾股计算,我们取这一上限为r_max。正因如此,我们规定高斯分布的标准差为:

高斯分布的标准差与Gun.sigma的关系

同时新的散布距离为r∈[– r_max, + r_max],新的概率分布函数Q(r)=c·P(r)为原来函数的c倍, 满足新的归一化条件(从– r_max积分到+ r_max为1)。这样,每一发炮弹的落点均可以用这个概率模型表达出来,剩下的事情就可以交给程序了:通过生成随机数,来模拟主炮的落点,判断是否能够命中靶心。

下图表示了二维高斯分布的样子,来源为英文维基Gaussian funtion词条。

二维高斯分布的样子

接下来,就让我们在主炮对象Gun()下,定义一些随机开炮、判断命中、绘制散布圈的函数吧:

# 定义对象: 主炮

class Gun:

    def __init__(self, name = '406mm_mk6',  #  炮名

                 shell_type = 'HE',         #  弹种 (暂时没用上)

                 shell_num = 3,             #  弹数

                 damage_per_shell = 156,    #  强满单发伤害

                 period = 24.02,            #  强满面板射速 (s)

                 compensation = 1.05,       #  舰炮补正(注意不是护甲补正)

                 sigma = 1,                 #  二维高斯散布的sigma

                 speed = 10,                #  弹速 (暂时没用上)

                 aoe_area = 15,             #  着弹范围 (AOE圈半径)

                 xz_shift = 19,             #  XZ偏移 (我理解为了竖直方向散布)

                 dispersion = 22,           #  散布范围 (我理解为了水平方向散布)

                ):

        self.name = name

        self.shell_type = shell_type

        self.shell_num = shell_num

        self.damage_per_shell = damage_per_shell

        self.period = period

        self.compensation = compensation

        self.sigma = sigma

        self.speed = speed

        self.aoe_area = aoe_area

        self.xz_shift = xz_shift

        self.dispersion = dispersion

    # 假设炮弹的落点是在最大散布圈(dispersion,散布圈半径)内高斯分布,

    # 最大散布圈相当于高斯分布标准差的sigma倍

    def shoot_one_shell(self):  # 打一发主炮

        tmp_pa = np.random.rand(1)*np.pi*2 # positional angle

        tmp_max_r = np.sqrt((self.dispersion*np.cos(tmp_pa))**2 + (self.xz_shift*np.sin(tmp_pa))**2)

        tmp_r = np.random.normal(loc = 0, scale = tmp_max_r / self.sigma) 

        while np.abs(tmp_r) > tmp_max_r:

            tmp_r = np.random.normal(loc = 0, scale = tmp_max_r / self.sigma) 

        tmp_x, tmp_y = np.cos(tmp_pa) * tmp_r, np.sin(tmp_pa) * tmp_r

        return (tmp_x, tmp_y)

    def shoot_one_round(self): # 打一轮主炮, 即shell_num发

        tmp_pa = np.random.rand(self.shell_num)*np.pi*2 # positional angle

        tmp_max_r = np.sqrt((self.dispersion*np.cos(tmp_pa))**2 + (self.xz_shift*np.sin(tmp_pa))**2)

        tmp_r = np.random.normal(loc = 0, scale = tmp_max_r / self.sigma) 

        while np.sum(np.abs(tmp_r) > tmp_max_r) > 0:

            arg_out = np.where(np.abs(tmp_r) > tmp_max_r)

            tmp_r[arg_out] = np.random.normal(loc = 0, scale = tmp_max_r[arg_out] / self.sigma) 

        tmp_x, tmp_y = np.cos(tmp_pa) * tmp_r, np.sin(tmp_pa) * tmp_r

        return (tmp_x, tmp_y)

    def shoot_n_round(self, n): # 打一轮主炮, 即shell_num发

        tmp_pa = np.random.rand(self.shell_num * n)*np.pi*2 # positional angle

        tmp_max_r = np.sqrt((self.dispersion*np.cos(tmp_pa))**2 + (self.xz_shift*np.sin(tmp_pa))**2)

        tmp_r = np.random.normal(loc = 0, scale = tmp_max_r / self.sigma) 

        while np.sum(np.abs(tmp_r) > tmp_max_r) > 0:

            arg_out = np.where(np.abs(tmp_r) > tmp_max_r)

            tmp_r[arg_out] = np.random.normal(loc = 0, scale = tmp_max_r[arg_out] / self.sigma) 

        tmp_x, tmp_y = np.cos(tmp_pa) * tmp_r, np.sin(tmp_pa) * tmp_r

        return (tmp_x, tmp_y)

    def center_hit_per_round(self, n=1): # 打一轮主炮,命中中心多少发?

        hit_pos = self.shoot_n_round(n)

        return np.sum(np.sqrt(np.sum(np.square(hit_pos), axis = 0)) < self.aoe_area)/n

    def draw_dispersion_range(self, **kwargs): #画出散布圈

        dsp_patch = patches.Ellipse(xy = (0,0) , 

                            width = self.dispersion*2, height = self.xz_shift*2, **kwargs)

        plt.gca().add_patch(dsp_patch)

利用上面的Gun()对象, 我们初始化以下四门主炮:美式406mm MK6(MK6,us_mk6),日式410mm连装炮(410,jp_410),英式356mm四联装炮(大哥炮,uk_356),以及厌战装备MK6的特殊情况(ws_mk6, ws就是Warspite哇死白头了)。注意:减面板点散布n时,我用了下述公式:

1. 水平散布: 直接减就行,也就是:

2. 竖直散布: 减量要通过竖直/水平比来调整,也就是:

换句话说,厌战散布-5使得其水平散布-5(为17),但竖直散布约-4.3(为14.7)。

us_mk6 = Gun(name = '406mm_mk6',  shell_num = 3, damage_per_shell = 156, compensation = 1.05, period = 24.02, xz_shift = 19, dispersion = 22)

jp_410 = Gun(name = '410mm_twin', shell_num = 2, damage_per_shell = 154, compensation = 1.10, period = 20.02, xz_shift = 16, dispersion = 20)

uk_356 = Gun(name = '356mm_quad', shell_num = 4, damage_per_shell = 108, compensation = 1.05, period = 29.19, xz_shift = 19, dispersion = 25)

ws_mk6 = Gun(name = '406mm_mk6', shell_num = 3, damage_per_shell = 156,  compensation = 1.00, period = 24.02, xz_shift = 19*(22-5)/22, dispersion = 22-5)

2. 散布圈的可视化:

在我们计算之前,让我们来先可视化一下上述定义的四门主炮的散布圈。我们让四门主炮分别进行500轮固定靶射击,假设固定靶位于坐标原点(0,0),射击结果由下述代码模拟/绘制:

plt.figure(figsize = (8,8))

# 瞄准(0,0)

plt.axhline(0, ls ='--', color ='grey', lw =2)

plt.axvline(0, ls ='--', color ='grey', lw =2)

# 射击轮次

shoot_round = 500

# 美帝妹控6

x, y = us_mk6.shoot_n_round(shoot_round)

plt.scatter(x, y, color=  'b', marker = '.', label = '406mm MK6', alpha = 0.5)

us_mk6.draw_dispersion_range(ec = 'b', fc = 'none', lw = 1.2)

# 本子410

x, y = jp_410.shoot_n_round(shoot_round)

plt.scatter(x, y, color=  'r', marker = '.', label = '410mm连装炮', alpha = 0.5)

jp_410.draw_dispersion_range(ec = 'r', ls = '-', fc = 'none', lw =1.0)

# 英吹356

x, y = uk_356.shoot_n_round(shoot_round)

plt.scatter(x, y, color=  'g', marker = '.', label = '四连装356mm', alpha = 0.5)

uk_356.draw_dispersion_range(ec = 'g', ls = ':', fc = 'none', lw =2)

# 厌战mk6

x, y = ws_mk6.shoot_n_round(shoot_round)

plt.scatter(x, y, color=  'k', marker = '.', label = '406mm MK6(厌战)', alpha = 0.45)

ws_mk6.draw_dispersion_range(ec = 'k', ls = '--',  fc = 'none', lw =2)

# 物理命中区域

dsp_patch = patches.Ellipse(xy = (0,0) , 

                            width = 15*2, height = 15*2, color = 'grey', alpha = 0.35,

                            label = '物理命中范围 (15)')

plt.gca().add_patch(dsp_patch)

# 绘制图例等

plt.legend(framealpha = 0.8, loc = 4, 

           ncol = 2, handletextpad = 0.5, columnspacing = 0.2)

plt.gca().set(aspect = 1, xlim = (-30, 30), ylim = (-30, 30), 

              xlabel = '水平偏移', ylabel='竖直偏移', 

              title = '%d轮主炮射击固定靶的散布圈示意'%shoot_round)

plt.tight_layout()

plt.savefig('disp_散布圈示意图.jpg', dpi = 150)

绘制结果就是上图了,每一发炮弹的落点已用不同色的小圆点标识(见图例),每一种主炮的散布圈也用对应颜色的椭圆标记了出来。显然,我们都知道主炮子弹数越多,散布圈越大,所以四联356>三联MK6>双联410。物理命中圈由灰色阴影区标记——我们可以看见一件很恐怖的事情:厌战改的散布圈基本跟物理命中范围重合,这也就给了厌战改对固定靶极高的物理命中率。

注意:这里的命中均未考虑固定靶子的大小。也就是说,当靶子是有一定面积的时候,实际的物理命中范围可能比我这里估计的要大。因为我并不知道这个物理命中具体是怎么判定的,所以这里靶子就当做点了。

2. 单轮主炮射击的物理命中数期望

接下来,我们让每个主炮射击10000次, 来统计平均每轮主炮的命中数,计算不同主炮、不同散布参数下的主炮射击固定靶的命中率:

# 物理命中数

phy_hit_per_round = np.array([us_mk6.center_hit_per_round(10000), 

                              jp_410.center_hit_per_round(10000), 

                              uk_356.center_hit_per_round(10000), 

                              ws_mk6.center_hit_per_round(10000), ])

# 实际炮弹数

tot_hit_per_round = np.array([us_mk6.shell_num, 

                              jp_410.shell_num, 

                              uk_356.shell_num, 

                              ws_mk6.shell_num])

# 物理命中率

hit_rate = phy_hit_per_round / tot_hit_per_round * 100

# 棒状图绘图参数:

ind = np.arange(4)  # the x locations for the groups

width = 0.35  # the width of the bars

fig, ax = plt.subplots(1,1, figsize = (10,6))

rects1 = ax.bar(ind - width/2, phy_hit_per_round, width,

                label='固定靶物理命中数')

rects2 = ax.bar(ind + width/2, tot_hit_per_round, width,

                label='发射炮弹数')

# ax.set_yticks(np.arange(0,5))

ax.set_xticks(ind)

ax.set_xticklabels(['406mm MK6\n%.1f%%'%(hit_rate[0]), 

                    '410mm连装炮\n%.1f%%'%(hit_rate[1]), 

                    '四联装356mm\n%.1f%%'%(hit_rate[2]), 

                    '厌战MK6\n%.1f%%'%(hit_rate[3])])

ax.set_ylabel('单轮主炮的炮弹数目')

ax.legend(loc=4, framealpha = 0.9)

plt.tight_layout()

plt.savefig('disp_固定靶命中率.jpg', dpi = 150)

四种主炮单轮平均命中数(蓝色)和总炮弹数(橙色)如上面的柱状图所示,四种主炮各自的命中率也各做了标识。

总体而言,MK6平均每轮可以命中~2.34发,410平均每轮可以命中~1.73发,这和实战还算接近——MK6单轮打固定靶中两炮很容易,三炮全中似乎比较困难;410每轮两炮都中的概率很高,只有脸比较黑的时候才只中一发。厌战可以将MK6的命中率从78%提高到96%,这对厌战打固定靶的输出提升相当明显。

据舰队里某位匿名老干部指出,小圣做计算的时候,410的命中率是0.83,MK6是0.72,应该跟我这里的数值比较接近了,然而这里模拟出来的要高~5%的样子(见海事局《前排舰船炮击输出性价比和生存性价比_粗量化比较》中关于后排输出性价比的图表)。

实战中,我们有可能看到这样的情况(同样据这位老干部):

13-4很明显,自律战列主炮首先打维修S,厌战一轮下去中3发直接带走,中2发点燃带走,长门就不行,长门很难第一炮3颗子弹带走维修S

那个,反正我13-4 boss战没上过长门,所以并不知道长门是什么样的,而且我13-4打得也不是很多,所以欢迎读者实测反馈。

3. 减散布对主炮命中数的提升

接下来,我们仍然对三种主炮进行测试,探究这一模型下【主炮命中数提升率】和【散布减少量】的关系。 这里,散布的减少量取以下数列:

我们把每个散布减少量a_i带入到各个主炮当中,计算给定a_i下,10000轮主炮射击的单轮平均命中数H_i,并与散布不减少时(H_0)进行比较。

# 定义三个数组,记录三种主炮各自命中率

us_mk6_curve = np.array([])

jp_410_curve = np.array([])

uk_356_curve = np.array([])

# 散布空间(a_n)

disp_space = np.arange(0, 8.2, 0.2)

for neg_disp in disp_space:

    tmp_mk6 = Gun(name = '406mm_mk6', shell_num = 3, xz_shift = 19*(22-neg_disp)/22, dispersion = 22-neg_disp)

    us_mk6_curve = np.append(us_mk6_curve, tmp_mk6.center_hit_per_round(10000))

    tmp_410 = Gun(name = '410mm_twin', shell_num = 2, xz_shift = 16*(20-neg_disp)/20, dispersion = 20-neg_disp)

    jp_410_curve = np.append(jp_410_curve, tmp_410.center_hit_per_round(10000))

    tmp_356 = Gun(name = '356mm_quad', shell_num = 4, xz_shift = 19*(25-neg_disp)/25, dispersion = 25-neg_disp)

    uk_356_curve = np.append(uk_356_curve, tmp_356.center_hit_per_round(10000))

# 误差:取二项分布误差 sqrt(Np(1-p))/N

us_mk6_err = np.sqrt(us_mk6_curve/3. * (1 - us_mk6_curve/3.) / 10000)

jp_410_err = np.sqrt(jp_410_curve/2. * (1 - jp_410_curve/2.) / 10000)

uk_356_err = np.sqrt(uk_356_curve/4. * (1 - uk_356_curve/4.) / 10000)

# 开始绘图

plt.figure(figsize = (8,6))

ax = plt.gca()

plt.errorbar(disp_space, us_mk6_curve/us_mk6_curve[0], yerr = (us_mk6_err**2+us_mk6_err[0]**2)**0.5,

             color = 'b', marker = 's', capsize = 5, label = '406mm MK6')

plt.errorbar(disp_space, jp_410_curve/jp_410_curve[0], yerr = (jp_410_err**2+jp_410_err[0]**2)**0.5,

             color = 'r', marker = '>', capsize = 5, label = '410mm连装炮')

plt.errorbar(disp_space, uk_356_curve/uk_356_curve[0], yerr = (uk_356_err**2+uk_356_err[0]**2)**0.5,

             color = 'g', marker = 'o', capsize = 5, label = '356mm四连装')

# 添加特定点:乔五、复读机、厌战

plt.text(5, 1.30, '厌战改', color= 'k', ha ='center', va='center', fontsize = 16)

plt.axvline(5, 0.85, 0.90, color = 'k')

plt.text(2, 1.30, '乔五\n或 复读机', color= 'k', ha ='center', va='center', fontsize = 16)

plt.axvline(2, 0.82, 0.87, color = 'k')

plt.text(4, 1.28, '乔五+复读机', color= 'k', ha ='center', va='center', fontsize = 16)

plt.axvline(4, 0.79, 0.84, color = 'k')

plt.legend(framealpha = 0.6, loc = 4, 

           ncol = 1, handletextpad = 0.5, columnspacing = 0.2)

ax.set(xlim = (0, 8), ylim = (1.0, 1.32), xlabel = '散布降低值', ylabel = '固定靶单轮主炮命中数增幅')

ax.set_yticks([1.0, 1.1, 1.2, 1.3])

ax.set_yticklabels(['0%', '10%', '20%', '30%'])

plt.tight_layout()

plt.savefig('disp_散布降低收益.jpg', dpi = 150)

三种主炮的单轮命中数增幅与散布降低值之间的关系如上图所示。图里面的误差棒通过二项分布的标准差进行计算。

可以看到,在散布减0~3之间时,三种主炮的命中数提升幅度差别不大,差不多每减1点散布,主炮命中数提升4%。换句话说,对于常见主炮而言,比起空一个装备位置,98式复读机大致可以提高8%的主炮命中数。至于具体相当于提高多少输出,还需要考虑这个装备对炮击面板的影响,以及主炮输出占总输出的比例,这超过了这篇文章的考虑范围。有兴趣的读者可以算一算。

在散布减少3以上时,率先掉队的是紫410,因为这个炮本身的面板散布就是最小的。减少散布超过4点后,410的两发炮弹都能准确落到15格的物理命中范围内,这时再减散布就没有额外收益了。所以,最好不要给厌战带紫双联410,这对主炮物理命中提升幅度几乎要比MK6小10%。

由于四联装356的面板散布很大,所以在目前可以搭配到的减面板散布的范围内(0~7),给大哥炮减散布都是正收益,而且这个收益的线性很不错。

MK6的线性收益范围大概是0~6,所以厌战带MK6收益很高。厌战改在主炮散布减5点并使用MK6时,对固定靶的物理命中期望为2.87发/轮,相对于没有散布降低时提高了22%的物理命中率。带上MK6+复读机后,厌战基本可以做到打固定靶每发必中,但是复读机本身的收益是不如黑/白蛋的。利用炮击伤害公式计算,对于600炮击值的战列舰而言,黑弹所带来的主炮伤害提升大约为14.4%;而复读机算散布收益为8%,则总主炮伤害提升大约是12.9%。上述的数字与目前的一些实战结论符合得比较不错(指这篇《九八式延迟射击装置13-4道中对比超重弹弹链的收益测试》​)。

这篇文章里,魔法少女提到减散布的收益不是线性的。在这里我的结论是:对单一固定靶进行主炮输出,减散布的收益在一定的阈值内线性较好。这可能说明我的模型有问题,或者理论模型跟实战偏差还是很大的,当然,也有可能是减2点散布提升的只是8%的期望命中数,这需要相当大量的实战统计才能看出真正的收益变化。我当然觉得我的模型还挺好的(至少有点用吧),但实践是检验真理的唯一标准,理论模型和数值模拟肯定解决不了全部的问题。

总之,关于碧蓝航线跨射主炮的散布模型,我们先讨论这么多,欢迎读者的讨论、补充和实战的检验。

最后晒张图表示自己并没有很云玩家吧,这个是更新UI那天截的图。亚利桑那赛高!

硫磺岛 Overflood 初稿于2019年05月31日

PS: 啊专栏编辑起来真费劲,希望B站早日支持markdown格式,好歹整个LaTeX语法支持吧。。

PS2:这个栏目就先叫可视化航线吧,以后有心情就随缘更新,主要讲如何用Python把游戏里的一些机制可视化(虽然不见得对)。