【BepInEX】清版射击游戏的自动瞄准实现

本文最后更新于 2024年8月6日 凌晨


好吧其实我也不知道为什么对这个这么感兴趣。在一年前有一款名叫《20 Minutes Till Dawn》的游戏以清版射击+rouge元素+超级性价比的售价吸引了我的游玩,当时作者还没有推出现在的自动瞄准功能,所以我通过一些简陋的方式实现了这个自瞄,这种方式不涉及游戏源码的修改,而是通过外部的计算机视觉处理识别目标进而实现目标的定位。我想以一种轻松愉悦的风格记录下我的探索过程,当然你还可以在我的基础上做更多有意思的事情,希望你们喜欢。另外需要提醒一下,你最好有一定的C或者这个系列的语言基础,不然你可能在修改代码上寸步难行。

游戏介绍

我当时正在关注一款名叫《Wind Runner》的游戏,《Wind Runner》的制作团队此前制作的一款游戏就是这款《AKane》,价格非常便宜,因此我想购买先来磨合一下制作团队的风格。这是一款爽游,有点类幸存者,但没有肉鸽元素,后期比较看重反应力,同时也看重对游戏环境的感知,虽然游戏体量非常小,但是却非常耐玩,因此将其作为我的研究对象。

工具介绍

dnSpy下载

基于Unity制作的游戏主要有两类:一种是基于C#编写,一种是通过C++进行编写。从代码编写来看,我这种万年只用Python的人来看这二者没有很大的区别,无非是一个更加精良,一个更加经典,但是这两者最终编译成的游戏项目结构是有很大不同的。我们之后所有的代码都是基于C#进行修改,结构目录也大抵相同,如下图所示:

C#类型项目结构

游戏的核心编译到了Akane\akane_win64\Akane_Data\Managed\Assembly-CSharp.dll文件中。这就是我们需要修改的核心文件。我们将使用dnSpy这个工具进行反编译,进而修改游戏的源码。官网的描述如下:

:bulb: dnSpy is a debugger and .NET assembly editor. You can use it to edit and debug assemblies even if you don’t have any source code available. Main features:

  • Debug .NET and Unity assemblies
  • Edit .NET and Unity assemblies
  • Light and dark themes

dnSpy的官方Release地址在这里,目前不知道为啥已经Archive了,但是无所谓,能用就行。下载后解压,运行dnSpy.exe即可启动dnSpy。

dnSpy使用

我们打开对应游戏的Assembly-CSharp.dll文件,然后将目录展开。另外有些经验分享一下:像小公司制作的Unity游戏基本逻辑的代码都没有一个单独的命名空间,而像财力雄厚的工作室制作的大多有比较规范的命名空间,比如微软站台的Moon工作室制作的奥日2。

:bulb: 这并不是说工作室的水平不合乎规矩,因为dnSpy是反编译出来的结果,实际上的源代码我们也无从得知,但我们至少可以得知源码的一些逻辑和命名风格,并在此基础上进行修改。

我们修改一下子弹的装填。通过敏锐的嗅觉可以定位到这里:

image-20231003214542013

将其值修改为1f,此时装填速度基本可以做到无限连发了。修改完后点击编译,然后保存代码后运行游戏就可以看到修改效果啦!

数据结构

使用一个队列保存数据,因为server端在不断地接受udp最新的buffer以防止其阻塞把新来的数据包给丢掉,所以我们要把数据存进一个长度受限的队列里,当接收到client端发过来的数据后就马上存入队列。当队列满的时候,把最早进入队列的数据给弹出,这样就能保证队列里面永远都有最新的坐标位置信息的数据了。

当然有人会问为什么需要队列来保存数据,我直接从udp那里拿到数据就进行操作不好吗?需要注意,每一个Enemey都有其单独的GameObjectInstanceID,他们都是同时写入到同一个端口的,事实上玩家的数据也是写入这个端口他们可以看作是不同的线程在同时执行。但有趣的是,经过我的测试发现,控制Enemy移动的Move()函数似乎没有使用多线程,这意味着我们无法通过线程来对数据进行划分,所以才有了GameObjectInstance作为标识符(之后我们简称为_id)。总之使用队列来存储这一组数据就是为了尽可能多的保留战场的情况的同时,也保存最新的战场情况。

那么我们所有的数据都存入到队列queue里面后,下一步我们就需要筛选符合的数据了。我们通过GameObjectInstanceID对敌人进行区分,但可能一个队列里面有多个_id相同的坐标数据,其中排在后面的是最新的数据。我们需要将其筛选到队列里面每一个_id都是唯一的,且保留的是最新的数据。我是使用一个哈希表实现这个功能的,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_max_elements(queue,maxlen=16):
id_dict = {} # 用于记录每个id对应的最新元素
max_elements = deque() # 存储当前id不同的最大元素集合

for element in queue:
_id, x, y = element[:3]

# 如果当前id已经存在于字典中,更新对应的元素
if _id in id_dict:
id_dict[_id] = element
else:
# 当前id不存在于字典中,将其添加到字典和双端队列中
id_dict[_id] = element
max_elements.append(element)

# 检查队列是否已满,如果已满,则将队列最前端的元素剔除
if len(max_elements) > maxlen:
oldest_element = max_elements.popleft()
# 从字典中删除被剔除的元素
del id_dict[oldest_element[0]]

return list(max_elements)

交互行为

我们将在Python中执行脚本。我们使用UDP接受传入来的数据,然后将其按照自己指定的规则进行解析。

1
2
3
4
5
UdpClient udpClient = new UdpClient();
udpClient.Connect("127.0.0.1", 11000);
byte[] bytes = Encoding.ASCII.GetBytes(string.Format("Self Pos :{0},{1},{2}", "m2022", base.transform.position.x, base.transform.position.z));
udpClient.Send(bytes, bytes.Length);
udpClient.Close();
1
2
3
4
5
UdpClient udpClient = new UdpClient();
udpClient.Connect("127.0.0.1", 11000);
byte[] bytes = Encoding.ASCII.GetBytes(string.Format("Shooter Enemy:{0},{1},{2}", base.GetInstanceID(), base.transform.position.x, base.transform.position.z));
udpClient.Send(bytes, bytes.Length);
udpClient.Close();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
dest_addr = ("127.0.0.1", 11000)
udp_socket.bind(dest_addr)
queue = deque(maxlen=16)
st = time.time()
while True:
receive_data, client_address = udp_socket.recvfrom(1024)
queue.append(receive_data.decode("gbk").split(":")[-1].split(','))
res = get_max_elements(queue)
if time.time() - st > 0.5:
st = time.time()
print(res)
else:
continue
# print(res)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import matplotlib.pyplot as plt

with open("test.txt") as f:
data = f.readlines()
print(data[0])
pos_data = [each.split(":")[-1][:-3].split(",") for each in data]
enemy_id = []
for i in range(9):
if pos_data[i][0] not in enemy_id:
enemy_id.append(pos_data[i][0])
print(enemy_id)
x_ = [[float(each[1]) for each in pos_data if each[0] == id]for id in enemy_id]
y_ = [[float(each[2]) for each in pos_data if each[0] == id]for id in enemy_id]
print(x_[0][0])
for i in range(len(enemy_id)):
plt.scatter(x_[i][:],y_[i][:])
plt.show()

由此即可观测到玩家彼时的轨迹数据:

Player Track Trace

坐标映射

我的屏幕分辨率为3440*1440,但是经过测试,不论在游戏内设置游戏分辨率为何值,其transform.position的尺度都没有变化。因此你只需要根据你自己使用的屏幕分辨率建立对应的映射关系即可。从之前的图中不难看出,坐标的映射是一种线性的关系。根据截图的数据进行拟合:

image-20231003211002752

我们假设我们得到的Unity数据和我们期望的目标真实屏幕坐标位置满足如下关系:

那么损失函数定义为:

要使损失函数最小,可以将损失函数当作多元函数来处理,采用多元函数求偏导的方法来计算函数的极小值。例如对于一维特征的最小二乘法,对损失函数求偏导并使其为0得:

联立求得:

当然你可以直接使用线性回归在线工具进行数据拟合得到如下结果。需要注意,横纵坐标的映射规则是不同的,因此需要拟合两次。

1696347488750

拟合表达式为:

Next:

1
2
3
4
5
6
7
8
9
10
11
UdpClient udpClient = new UdpClient();
udpClient.Connect("127.0.0.1", 11001);
Vector3 vector = Camera.main.WorldToScreenPoint(base.transform.position);
byte[] bytes = Encoding.ASCII.GetBytes(string.Format("Enemey Regular pos:{0},{1},{2}", new object[]
{
base.GetInstanceID(),
vector.x,
vector.y
}));
udpClient.Send(bytes, bytes.Length);
udpClient.Close();

【BepInEX】清版射击游戏的自动瞄准实现
https://cybercolyce.cn/2024/06/03/清板射击/
作者
L4k3d22
发布于
2024年6月3日
许可协议