继上一篇,我们有了模板匹配这一强大又易上手的工具后,在这一篇中我们可以开始尝试实现针对游戏的基础操作了。本篇将开始搭建一个进攻的流程。
一字划
容易想到,一字划是最简单有效的进攻方案,不管是什么进攻方案,我们都可以将其转化为在某一条线段上的一字划操作。
均等一字划
假设我们现在要在村庄的某条边上平铺10条飞龙,那么我们可以这样去描述这个任务:
村庄的某条边实际上相当于某个线段,我们在测出两端端点的坐标后(详情参见第1篇中的坐标章节),就可以计算这条线段的十个十等分点。我们以下文的坐标为例
start = (1000, 1000)
end = (500, 500)
容易想到,我们可以用for循环遍历range(10),而每次需要的坐标只要用这两个端点的x坐标之差的十分之一(y坐标同理),乘上序数就可以了:
x_spacing = (end[0] - start[0]) / 10
y_spacing = (end[1] - start[1]) / 10
for i in range(10):
goal_point = (round(start[0] + i * x_spacing), round(start[1] + i * y_spacing))
print(goal_point)
输出结果为
(1000, 1000)
(950, 950)
(900, 900)
(850, 850)
(800, 800)
(750, 750)
(700, 700)
(650, 650)
(600, 600)
(550, 550)
很容易发现其包含了开始的端点而不包含末尾的端点,实际上稍微思考也能发现的确如此,用i遍历range(10),i会从0~9依次取值,为了解决偏移,我们只需要把i加0.5即可实现均分:
goal_point = (round(start[0] + (i + 0.5) * x_spacing), round(start[1] + (i + 0.5) * y_spacing))
根据小学二年级学过的知识,这实际上是把一条线段分作十段,再取每段的中点,我们轻而易举就实现了在某条指定起始和结束端点的线段上均等地取点。在实操中,要实现下兵,只需要再补上一行click的代码即可。我们把10条飞龙的数量用n代替,改变每次的起点和终点,推广到更一般的情况:
def line_attack(start, end, num, troop_location):
"""单一兵种的一字划"""
if num:
x_spacing = (end[0] - start[0]) / num # 计算每个下兵点位坐标间隔
y_spacing = (end[1] - start[1]) / num
pyautogui.click(troop_location) # 选中兵种
time.sleep(0.1)
for i in range(num): # 开始下兵
goal_point = (round(start[0] + (i + 0.5) * x_spacing),
round(start[1] + (i + 0.5) * y_spacing))
pyautogui.click(goal_point)
随机一字划
如果读者按照上述方案实践,会很容易发现这样的操作过于机械化——我指的是,它看起来太不像人类的游戏行为了!如此工整地从起点滑行至终点,恐怕会大大提升被检测为脚本的概率。要改变也不难,我们用random模块加入噪声即可。方案可以有很多,我们这里直接抛弃用i乘spacing的算法,改为每次都用一个0~1的随机数,同时乘上两点的x与y坐标的差值。注意,每次是用同一个随机数同时乘以x与y,才可以保证该点为线段上的一点!这实际上相当于取线段上某一比例的点。为了更快兼容前述代码,直接用0~num乘spacing也是等效的
# 加入噪声
noise = random.uniform(0, num)
time.sleep(random.uniform(0, 0.15))
goal_point = (round(start[0] + noise * x_spacing), round(start[1] + noise * y_spacing))
当然,用这种写法后,下兵的位置将在线段上随机选择,并不能保证兵力均等。可以为line_attack函数加上开关,针对不同兵种选择不同的进攻方案。或者,读者也可以自行尝试将所有n等分点存入某个乱序后的列表里,每次从中随机取出一点,这样既能保证不重复也不遗漏,也可以实现随机均分;同时,每个点取点后,也可以用微小的随机数造成微量偏移。关于这一部分的细节,不再过多赘述。
不同流派的实现
有了针对某一兵种在指定线段上一字划的方案以后,我们可以基于此函数扩展成不同流派。我们可以将进攻方案分为以下两类:
- 单侧进攻:所有的部队在某条边上一字划
- 环绕进攻:将部队分为四份,从村庄四条边进攻
1是针对部队数量没有那么多的情况,例如8848流派、龙流;2适用于偏向人海的进攻方案,例如胖弓蛮、超蛮流。当然,某个流派具体使用哪种方案,还是由人决定。如果想要随意地增减流派配置,每次运行脚本都能手动设置最适合的流派,一个扩展性强的做法是把所有流派写成一个recipes字典,再在程序中导入。这样就可以轻松实现基于简单的一字划实现不同流派的进攻。只需要在字典中写入某个流派使用的兵种,以及每个兵种分别的数量,再加上是单侧一字划还是绕村一周的进攻,就能满足绝大多数的需要。这个方案相比于为每个流派都专门写一段代码,一个极大的优点是,我们要实现不同流派,只需要修改配置文件中的recipes字典,而无需做代码层面的大规模改动。我们规定recipes字典的格式如下:
recipes = {
'流派1名称': {
'troop': {
'兵种1名称': 3, # 兵种1的数量,如3个石头人
'兵种2名称': 10, # 兵种2的数量
……
},
'spell': {
'法术1名称': 5, # 法术1的数量,如5个骷髅法术
'法术2名称': 6, # 法术2的数量
},
'mode': 'around' # around表示绕村一周的流派,line表示只在一侧全部派遣的流派
},
'流派2名称': {
……
},
……
}
字典的第一层是各个流派及所对应的配置,keys为各流派名称,values为各流派的详细配置。这样要选择流派,只需要每次运行时读取字典的keys,再做一个选择器供用户选择即可。各流派的详细配置分为troop、spell、mode三层,troop中写好用到的所有兵种的名称及数量,spell中写好所有用到的法术的名称及数量,程序运行时顺序读取兵种和法术配置,而mode指定上述提到的单侧进攻或环绕进攻。呼应前文,这里兵种和法术的名称需要与存储模板匹配图像的文件名相一致,这样程序就能按图索骥找到存放在指定文件夹下的模板图像。当然,如果读者还想实现更复杂的方案,例如对每一兵种都有不同的控制方案,比如炸弹人在单点下兵,石头人一字划,还可以进一步扩展recipes的格式,只是对于作者,这样的自由度实现日常打资源已然够用。
下面是完整的recipes代码,游戏中兵营容量和法术容量在不同游戏阶段几乎是一直在变化的,所以写每个兵种的数量时,考虑进这两个变量。它会在程序每次运行初始化时(或读取account配置文件)得到。
class ArmyRecipes:
def __init__(self, troop_capacity, spell_capacity):
self.recipes = {
# 弓箭野蛮(适用于低本兵营容量小于140)。对半开
'弓蛮': {
'troop': {
'barbarian': troop_capacity // 2 + troop_capacity % 2,
'archer': troop_capacity // 2
},
'spell': {},
'mode': 'around'
},
# 胖弓蛮。巨人12,弓蛮对半开
'胖弓蛮': {
'troop': {
'giant': 12,
'barbarian': (troop_capacity - 60) // 2 + troop_capacity % 2,
'archer': (troop_capacity - 60) // 2
},
'spell': {},
'mode': 'around'
},
# 超蛮流。超炸5,剩下全超蛮
'超蛮流': {
'troop': {
'super_wallbreaker': 5,
'super_barbarian': (troop_capacity - 40) // 5
},
'spell': {
'skeleton_spell': spell_capacity // 1,
},
'mode': 'around'
},
# 火龙流。先填满火龙,有剩余空间填气球
'火龙流': {
'troop': {
'fire_dragon': troop_capacity // 20,
'balloon': troop_capacity % 20 // 5
},
'spell': {
'skeleton_spell': spell_capacity // 1,
},
'mode': 'line'
},
# 狗球流。熔岩猎犬3,亡灵10,剩余填气球
'狗球流': {
'troop': {
'hound_number': 3,
'minion_number': 10,
'balloon_num': (troop_capacity - 110) // 5
},
'spell': {
'skeleton_spell': spell_capacity // 1,
},
'mode': 'line'
},
'雪弓流': {
'troop': {
'yeti': (troop_capacity // 30) + ((troop_capacity % 30) // 18
if (troop_capacity % 30) // 18 > (troop_capacity % 30) // 12
else 0),
'super_archer': (troop_capacity // 30) + ((troop_capacity % 30) // 12
if (troop_capacity % 30) // 12 > (troop_capacity % 30) // 18
else 0)
},
'spell': {
'skeleton_spell': spell_capacity // 1,
},
'mode': 'line'
},
'8848': {
'troop': {
'golem': 3,
'super_wallbreaker': 3,
'witch': 8,
'super_wizard': (troop_capacity - 210) // 10,
'wall_breaker': (troop_capacity - 210) % 10 // 2
},
'spell': {
'skeleton_spell': spell_capacity // 1,
},
'mode': 'line'
},
'掉杯': {
'troop': {
'giant': 1,
},
'spell': {},
'mode': 'line'
}
}
更复杂的进攻
有了一字划方案和流派配置文件后,我们可以以此为基底,组合成为更复杂的实现。我们将其定义在Attacker类中(其中data_manager是一个数据管理类,用来管理用户数据和大部分程序运行时多个模块都会用到的变量;而一些涉及英雄部署的方法和军队部署/一字划相比没有新的技术细节,在此省去):
import pyautogui
import time
import random
from img_processing import img_match
from data_manager import DataManager
class Attacker:
"""实现进攻的类"""
data_manager: "DataManager" # 声明依赖,方便开发时IDE自动补全
def __init__(self, data_manager):
self.data_manager = data_manager
self.x, self.y = pyautogui.size()
@staticmethod
def attack_icon_match(name):
"""进攻时匹配兵种图标,返回位置。只需传入兵种名称字符串,自动补全path。"""
return img_match('img/attack/' + name + '.png')
def line_attack(self, start, end, num, troops_location):
"""单一兵种的一字划"""
if troops_location and num:
x_spacing = (end[0] - start[0]) / num # 计算每个下兵点位坐标间隔
y_spacing = (end[1] - start[1]) / num
pyautogui.click(troops_location)
time.sleep(0.1)
if self.data_manager.noise_attack:
for i in range(num): # 开始下兵
# 加入噪声
noise = random.uniform(0, num)
time.sleep(random.uniform(0, 0.15))
goal_point = (round(start[0] + noise * x_spacing),
round(start[1] + noise * y_spacing))
pyautogui.click(goal_point)
else: # 普通一字划
for i in range(num): # 开始下兵
goal_point = (round(start[0] + (i + 0.5) * x_spacing),
round(start[1] + (i + 0.5) * y_spacing))
pyautogui.click(goal_point)
def mixed_line_attack(self, start, end, troops_dict, location_dict, side, remainder=0):
"""多兵种的一字划"""
for troop_name, num in troops_dict.items():
# 由于兵种数量可能不能整除4,所以需要在某一侧进攻时在数量上加上余数,用remainder变量控制
self.line_attack(start, end, int(num // side + remainder * num % side), location_dict[troop_name])
def around_attack(self, troops_dict):
"""利用混合一字划,形成环绕村庄一周的进攻"""
# 初始化进攻
location_dict = self.attack_init(troops_dict)
# 滚动鼠标前村庄上端两侧的进攻操作,写成匿名函数形式,随机调用
up_operation = [lambda:
self.mixed_line_attack(self.data_manager.locations['attack']['before_scroll']['village_up'],
self.data_manager.locations['attack']['before_scroll']['village_left'],
troops_dict, location_dict, side=4),
lambda:
self.mixed_line_attack(self.data_manager.locations['attack']['before_scroll']['village_up'],
self.data_manager.locations['attack']['before_scroll']['village_right'],
troops_dict, location_dict, side=4),
]
# 滚动鼠标后村庄下端两侧的进攻操作,写成匿名函数形式,随机调用
down_operation = [lambda:
self.mixed_line_attack(self.data_manager.locations['attack']['after_scroll']['village_down_right'],
self.data_manager.locations['attack']['after_scroll']['village_right'],
troops_dict, location_dict, side=4),
lambda:
self.mixed_line_attack(self.data_manager.locations['attack']['after_scroll']['village_down_left'],
self.data_manager.locations['attack']['after_scroll']['village_left'],
troops_dict, location_dict, remainder=1, side=4),
]
# 乱序
random.shuffle(up_operation)
random.shuffle(down_operation)
# 4次进攻操作
up_operation[0]()
self.hero_deploy() # 第1次部署英雄
self.spell_deploy()
up_operation[1]()
# 第三次进攻前,首先需要将屏幕下移
pyautogui.moveTo(x=int(self.x*0.5), y=int(self.y*0.5))
pyautogui.scroll(-int(self.data_manager.y * 0.5), )
time.sleep(1)
down_operation[0]() # 后2次进攻调用down_operation
down_operation[1]()
self.hero_abilities() # 第4次开技能
# 4次进攻结束,可能出现有部分兵种没有派遣完的现象,再重复执行一次
down_operation[0]()
def one_side_attack(self, troops_dict):
"""所有兵种全部下在一侧的一字划进攻方案"""
pyautogui.PAUSE = 0.01
location_dict = self.attack_init(troops_dict)
location_dict = {}
for troop_name in troops_dict.keys():
location_dict[troop_name] = self.attack_icon_match(troop_name)
self.mixed_line_attack(self.data_manager.locations['attack']['before_scroll']['village_up'],
self.data_manager.locations['attack']['before_scroll']['village_left'],
troops_dict, location_dict, side=1)
self.hero_deploy()
self.spell_deploy()
if any([self.AQueen_location, self.BKing_location, self.GWarden_location, self.RChampion_location, self.MPrince_location]):
time.sleep(random.randint(9, 15))
self.hero_abilities()
def spell_deploy(self):
"""施放法术"""
spell_dict = self.data_manager.army_recipes.recipes[self.data_manager.attack_mode]['spell']
location_dict = {}
for spell_name in spell_dict:
location_dict[spell_name] = self.attack_icon_match(spell_name)
self.mixed_line_attack(self.data_manager.locations['attack']['spell']['row_2_start'],
self.data_manager.locations['attack']['spell']['row_2_end'],
self.data_manager.army_recipes.recipes[self.data_manager.attack_mode]['spell'],
location_dict, side=1)
可以看到,在基于一字划方法line_attack扩展出的mixed_line_attack、around_attack和one_side_attack中,所有需要部署的兵种配置都是以字典的形式传入的,其中keys为兵种名称,与png模板文件保持一致,values为兵种数量,格式与我们的ArmyRecipes中相同。由于我目前配置的法术仅有骷髅法术,因此法术的部署也可以沿用一字划模板。