用AI代理玩3D FPS游戏
AI代理现在也成为了游戏的一部分。它们与环境互动、采取行动和做出决策都取决于正确的思考和正确使用LLMs/多模态模型。因此,为了学习AI代理在游戏环境中的工作方式:
- 首先,我们将使用Python的Ursina引擎创建一个第一人称3D动作射击游戏。
- 然后我们将创建一个基于子代理的多AI代理,使用LLM多模态模型,如DeepSeek V3用于文本和Mistral 3.1用于视觉能力,将它们组合在一起形成一个强大的多代理系统,将为我们玩游戏并赢得游戏。
我们将从头开始编写所有代码,从构建3D游戏引擎到使用LLM和视觉语言模型创建AI代理。
所有的代码(脚本+理论)都可以在我的GitHub仓库中找到。
我们的GitHub代码库目录如下:
ai-gaming-agent/
├── agent.py # 主要的代理文件,其中编码了AI代理
├── game.py # 主要的游戏文件,其中创建了游戏
├── game_without_ai.py # 用于在没有AI的情况下测试游戏的脚本
├── requirements.txt # 项目所需的依赖列表
├── REAMDME.md # 构建AI代理的逐步指南
game_without_ai
是你可以用来探索和根据你的需求调整游戏环境的脚本。
1、我们的AI代理架构
首先,让我们可视化我们在本指南中将要编码的完整架构:
首先,我们将使用Ursina引擎构建我们自己的3D第一人称射击游戏。这个环境是我们的代理将操作的地方。
- 第一步是让我们的代理感知游戏世界。 我们通过不断拍摄截图来获取视觉信息,并同时将玩家健康、敌人位置和瞄准细节等关键数据保存到一个json文件中。
- 然后,我们将捕获游戏的状态。 这意味着拍摄一张截图以查看视觉环境,并读取一个数据文件以获取精确的信息,比如玩家健康和敌人位置。
- 之后,我们的高级“指挥官”AI(视觉模型)会分析该截图和数据。 根据整体情况,它会决定一个总体策略,比如是否进行“积极进攻”或“防御性重新定位”。
- 接下来,“副官”AI(文本模型)会接收到指挥官的策略。 它会查看最新的实时数据,并选择最佳的即时行动来遵循该策略,例如“瞄准敌人”或“攻击”。
- 然后,这个简单的命令会被转换成实际的键盘按键和鼠标移动。 这允许代理物理上与游戏互动并控制玩家角色。
- 最后,这个过程会在一个连续、快速的循环中重复。 通过不断感知、思考和行动,代理可以对发生的事情做出反应并自主地玩游戏。
2、设置环境
为了避免任何问题,最好知道我在这个代理上构建时使用的当前Python版本,以避免版本不匹配。让我们打印出我正在使用的当前Python版本。
# 检查我的python版本
python --version
#### 输出 ####
3.10.0
我使用的是3.10,所以你应该使用相同的版本。另一个步骤是首先创建一个虚拟环境,这样可以避免项目中的依赖版本不匹配。
# 创建3.10环境
python -m venv venv
# (Windows/MacOS激活)
source .\venv\Scripts\activate
在整个博客中,我们只在需要时导入模块,因此我们可以理解每个模块的目的。我们需要首先安装这些模块。
- OpenAI: 用于与OpenAI的语言模型交互的客户端库。
- pynput: 用于控制和监控键盘和鼠标事件。
- mss: 用于程序化截图。
- Ursina: 用于构建交互式应用程序的Python 3D游戏引擎。
# 安装OpenAI API客户端(用于与OpenAI模型交互)
pip install openai
# 安装pynput(用于控制和监控键盘/鼠标)
pip install pynput
# 安装mss(用于截图)
pip install mss
# 安装Ursina(Python的3D游戏引擎)
pip install ursina
因此,我们并没有依赖太多模块。对于LLM调用,我们使用OpenAI,正如你所知。MSS部分是为了视觉模型步骤,因为它需要看到屏幕以了解发生了什么。
对于基于文本的LLM步骤,我们想通过Pynput控制键盘和鼠标,而Ursina是我们将用来设计游戏的主要引擎。
太好了!现在我们已经安装了依赖项,我们可以开始编写游戏环境。但在那之前,让我们先初始化LLM客户端模块。
from openai import OpenAI # 用于与AI模型API交互的官方OpenAI库。
# 初始化OpenAI客户端。
# 这会配置连接到AI模型API端点。
client = OpenAI(
base_url="https://api.studio.nebius.com/v1/", # 特定的API端点URL。
api_key="YOUR_API_KEY_HERE" # 重要:在此处粘贴您的API密钥
)
我们将通过OpenAI API调用开源LLM,因为通过API调用模型要快得多。但是,如果你有好的GPU,也可以使用Ollama模块本地运行LLM。
不过,在本指南中,我们将使用Nebius.ai基于API的开源LLM。现在,让我们开始编写游戏环境。
3、创建主屏幕窗口
我们必须在其中进行游戏的游戏中初始化游戏窗口。因此,让我们导入Ursina引擎模块,它将为我们创建该窗口。
# 导入ursina游戏引擎的所有类和函数
from ursina import *
# 创建主应用程序窗口
app = Ursina() # 初始化Ursina应用程序
###########################
# 所有游戏逻辑和实体将在此处定义
###########################
# 运行应用程序
app.run() # 启动Ursina游戏循环
Ursina可以通过调用Ursina()
模块本身轻松初始化,并且通过使用app.run
,我们基本上启动了游戏循环。
我们所有的游戏逻辑都将在这段代码之间,这将定义我们的游戏环境。但现在,让我们运行这段代码,看看它看起来是什么样子的。
到目前为止,我们的游戏窗口中没有任何内容,这就是为什么它是完全黑色的,如你所见,FPS在窗口右上角运行。
让我们开始添加实体,如第一人称视角、地面、枪支、敌人等等。所以我们来做吧。
4、构建地面、天空和第一人称控制器
我们必须将这个无聊的游戏窗口转变为一个交互式环境。为此,我们需要添加一个地面和一个第一人称控制器视图,以便我们可以看到环境中的情况。
让我们先创建地面。
# 设置地面
# 地面是一个大型平面,玩家可以在其上行走。
ground = Entity( # 创建一个大型地面平面供玩家行走。
model='plane', # 'model' 是形状
scale=64, # 'scale' 是大小
texture='grass',# 'texture' 给它一个草地外观
collider='box' # 'collider' 使其坚固,玩家不会掉下去
)
作为一个地面实体,我们使用带有草绿色纹理的平面土地。我们坚持使用一个小的地面尺寸64x64英尺,这足以让我们的代理探索。我们可以使用其他模型,比如地形以获得更逼真的游戏,但目前我们坚持使用平坦的表面。
一个重要的参数是collider
,我们将其设置为box
。当我们创建第一人称查看器时,它必须在草地上运行并且不会穿过它。通过将碰撞器定义为硬实体,我们确保没有任何东西可以穿过它。
在Ursina引擎中,我们可以使用自定义纹理创建实体,但它还提供了一些内置纹理,比如我们刚刚为土地使用的草地纹理。同样的,对于天空功能,我们可以简单地调用Sky()
模块来在我们的草地上添加天空。
让我们这样做。
Sky() # 创建默认的天空盒,使世界看起来不错。
让我们看一下我们的游戏目前的样子。
很酷,对吧?感谢天空,从3D游戏的角度来看,视图已经看起来不错了。但是为了探索我们的小土地,我们需要创建一个第一人称视图,这将作为我们游戏的主要角色,并且以后将由我们的AI代理控制。让我们这样做。
# 导入第一人称控制器以进行玩家移动
from ursina.prefabs.first_person_controller import FirstPersonController
# 创建玩家,一个第一人称控制器。
# 使用W、A、S、D移动并用鼠标四处看。
player = FirstPersonController(
y=2, # 在地面上方稍高处生成。
origin_y=-.5, # 调整相机相对于玩家原点的垂直位置。
speed=8 # 设置玩家的移动速度。
)
Ursina引擎有一个内置功能来创建第一人称视图,这是大多数游戏的基本要求。它也可以切换为第三人称视图,类似于你在GTA游戏中看到的那样。
目前,我们将从Ursina调用FirstPersonController并指定我们玩家的设置。
y=2
– 设置玩家在世界中的初始高度(在地面上方2个单位)。origin_y=-.5
– 相对于玩家原点调整相机的垂直位置(稍微降低)。speed=8
– 控制玩家使用W、A、S、D移动的速度。
这些设置来自Ursina引擎的默认文档,我们现在将坚持使用它们。然而,如果你的土地更大或充满了更多的敌人,你可以轻松地修改玩家设置。
让我们运行代码,看看我们的玩家如何移动。
很好!到目前为止,我们已经编码了基于草地的地面、有点像蒲公英的天空,以及一个可以使用W、A、S和D键移动的第一人称控制器。
你可以为我们的玩家添加许多其他功能,比如蹲下或跳跃,但为了保持简单和易于跟随,我们现在将坚持这些基本功能。
让我们继续创建游戏中的其他实体,使它看起来更像一个动作游戏。
5、编码武器功能
我们的玩家需要有一个武器来终止敌人。我们可以使用与创建草地和其他对象相同的Entity模块。
让我们先创建一个武器,然后我们可以通过代码来理解它是如何工作的。
# 创建一个枪实体并将其附加到相机。
gun = Entity( # 创建一个枪实体。
model='cube', # 设置模型为一个简单的立方体。
parent=camera, # 将枪附加到相机,使其随着玩家的视角移动。
position=Vec3(.5, -0.25, .25), # 将枪放在相机前面。
scale=Vec3(.3, .2, 1), # 设置枪的大小。
origin_z=-.5, # 调整枪的原点以正确旋转。
color=color.red, # 设置枪的颜色为红色。
on_cooldown=False # 添加一个自定义属性来跟踪枪的发射冷却时间。
)
为了简单起见,我们使用一个长立方体作为武器,它可以发射子弹。我们添加了旋转和位置调整,以将其放置在第一人称控制器的相机前面,使其看起来像是被玩家拿着的。我们还相应地定义了枪的大小。
让我们运行游戏,看看枪在我们的屏幕上显示的样子。
我们还可以使用**.obj**文件作为游戏中的实体,因为Ursina支持大量的3D对象文件格式。为了向你展示一个例子,让我们使用这个武器 .obj文件作为玩家的武器,使游戏看起来更真实。
gun = Entity(
parent=camera, # 将枪附加到相机,使其随着玩家的视角移动。
model='assets/ak_47.obj', # 使用自定义的AK-47模型。
color=color.dark_gray, # 设置枪的颜色为深灰色(对于没有纹理的模型)。
scale=0.9, # 设置枪的大小。
position=Vec3(0.7, -0.9, 1.5), # 将枪放在相机前面。
rotation=Vec3(0, 90, 0), # 旋转枪以面向正确方向。
on_cooldown=False, # 添加一个自定义属性来跟踪枪的发射冷却时间。
texture='assets/Tix_1.png', # 将纹理应用到枪模型。
)
我们使用**.obj**文件作为枪,并附带其纹理,该纹理存储在图像中。让我们渲染游戏,看看它看起来如何。
但我们目前将继续使用红色方块作为武器,以防开发者由于某些限制无法找到合适的**.obj**文件。这样可以更容易地复制。
现在,我们需要让这个武器发挥作用,也就是说,当左键点击时,它应该发射。让我们为我们的枪添加这个功能。
# 创建一个作为枪的子项的枪口闪光效果。
# 它最初是禁用的,当枪发射时会显示出来。
gun.muzzle_flash = Entity( # 创建枪口闪光实体作为枪的子项
parent=gun, # 设置父对象为枪实体
z=1, # 将枪口闪光放在枪的前面
world_scale=.5, # 设置枪口闪光的大小
model='quad', # 使用扁平的四边形模型作为闪光
color=color.yellow, # 设置颜色为黄色以产生闪光效果
enabled=False # 初始禁用枪口闪光
)
枪口闪光也是基于一个Entity对象,显然父对象是枪。位置和旋转是在经过一些实验后定义的,以正确对齐,而且由于枪口闪光是黄色的,我们将其颜色设置为黄色。
然而,它目前还不能按预期工作,因为我们需要让它表现得像一个真正的武器,即当左键按下时,闪光应短暂出现,就像真实的枪发射一样。
为了创建这种逻辑,我们需要编写一个函数来处理这个行为。
# 这个函数在玩家射击时被调用
# 它检查枪是否不在冷却中,播放声音,并显示枪口闪光。
# 它还会检查鼠标是否悬停在一个具有'hp'属性的实体上以造成伤害
def shoot():
if not gun.on_cooldown: # 只有当枪不在冷却中时才射击
gun.on_cooldown = True # 设置枪处于冷却状态
gun.muzzle_flash.enabled = True # 启用枪口闪光
from ursina.prefabs.ursfx import ursfx # 导入音效
ursfx( # 播放射击声音
[(0.0, 0.0), # 子弹声音的波形点
(0.1, 0.9), # 初期爆发的声音
(0.15, 0.75), # 接着衰减
(0.3, 0.14), # 进一步衰减
(0.6, 0.0)], # 声音结束
volume=0.5, # 设置声音的音量
wave='noise', # 使用噪声波形来制造枪声效果
pitch=random.uniform(-13, -12), # 随机的音高变化
pitch_change=-12, # 随时间改变音高
speed=3.0 # 声音效果的速度
)
invoke(gun.muzzle_flash.disable, delay=.05) # 延迟后禁用枪口闪光
invoke(setattr, gun, 'on_cooldown', False, delay=.15) # 延迟后重置冷却
if mouse.hovered_entity and hasattr(mouse.hovered_entity, 'hp'): # 如果鼠标悬停在敌人上
mouse.hovered_entity.blink(color.red) # 敌人闪烁红色
mouse.hovered_entity.hp -= 10 # 减少敌人的HP
shoot()
函数用于射击枪,显示效果,并对敌人造成伤害。让我们理解它的逻辑。
- 它首先检查枪是否不在冷却中,以防止玩家射击太快。
- 当射击时,枪被设置为冷却状态以防止连发。枪口闪光被启用以模拟枪发射,并在短时间内禁用,使用一个小的延迟。
- 使用
ursfx
播放枪声,随机音高变化以使每次射击感觉不同。如果鼠标指向一个具有hp
属性的敌人,敌人会闪烁红色以表示被击中,其健康值减少10。 - 最后,经过短时间的延迟后,冷却时间被重置,以便玩家可以再次射击。
我们的 shoot()
函数现在准备工作,但我们的武器和这个射击逻辑仍然没有连接。因此,我们需要在左键按下时创建一个简单的连接,让玩家能够射击武器。
# 创建一个每帧运行的更新函数
def update():
if held_keys['left mouse']: # 检查左键是否被按下
shoot() # 调用shoot函数
太好了!这个简单的连接将检查左键是否被按下,如果是,则触发枪的闪光效果。
让我们运行游戏,看看它看起来如何。
太好了!现在,当你左键点击时,它会显示闪光效果,模仿实际的武器发射。
到目前为止,我们已经编码了环境和第一人称相关的实体。现在,我们将添加一些障碍物,使环境更加真实和有趣。
6、改进游戏环境
开放世界游戏如GTA之所以高度互动,是因为它们的环境非常丰富和详细,包括建筑物、NPC等。我们不会那么深入,但我们可以让我们的环境感觉更真实一些。
我们可以添加一些墙实体和阳光,让环境看起来更自然。所以,让我们先创建一些墙实体。
# 导入random用于随机放置立方体
import random
# 循环创建随机立方体作为场景中的障碍物
# 这会创建一个网格的立方体,具有随机的高度
for i in range(16):
Entity(
model='cube', # 设置模型为立方体
origin_y=-.5, # 设置原点以对齐立方体与地面
scale=2, # 设置立方体的基础大小
texture='brick', # 应用砖块纹理到立方体
texture_scale=(1, 2), # 垂直拉伸纹理
x=random.uniform(-8, 8), # 在-8到8之间随机放置立方体的x位置
z=random.uniform(-8, 8) + 8, # 在-8到8之间随机放置立方体的z位置,偏移8个单位向前
collider='box', # 启用碰撞以防止实体穿过墙壁
scale_y=random.uniform(2, 3), # 随机化立方体的高度在2到3之间
color=color.hsv(0, 0, random.uniform(.9, 1)) # 分配随机的灰色色调
)
我们定义了一个循环来创建和放置多个立方体在场景中,作为墙壁或障碍物,使环境感觉更互动。
- 每个立方体使用砖块纹理来看起来像真正的墙壁,并且有随机的高度以避免统一性。
- 每个立方体的位置在一定范围内随机选择,因此它们自然地散布在整个区域中。
collider='box'
确保玩家或其他实体不能穿过这些墙壁,增加了移动和互动的真实性。- 不同的灰色调被分配给每个立方体,以提供多样性,使场景看起来不那么重复和更自然。
让我们运行游戏,看看更新后的环境现在看起来如何,有了新添加的墙壁。
酷!我们现在有一些随机放置的方块,这使得环境感觉更真实一些。当敌人试图终止我们的玩家时,我们可以躲在它们后面以获得掩护。
然而,环境仍然显得单调,缺乏阳光。添加一个阳光功能会让游戏环境更加自然和可玩。所以,让我们为我们的游戏环境创建一个阳光功能。
# 导入用于照明和阴影的着色器
from ursina.shaders import lit_with_shadows_shader
# 设置所有实体的默认着色器
Entity.default_shader = lit_with_shadows_shader
# 创建场景的阳光
sun = DirectionalLight()
# 设置阳光的方向
sun.look_at(Vec3(1, -1, -1))
Ursina
提供了内置的照明功能,使环境看起来更自然。
- 首先,我们导入正确的着色器模块,它支持照明和阴影。有不同的着色器用于各种效果,比如傍晚或夜晚,但我们使用的是早晨风格的照明,以匹配我们的
sky()
模块环境。 - 然后我们将此着色器设置为所有实体的默认值,这样场景中的每个物体都会自然地对光作出反应。
- 最后,我们创建一个方向光作为太阳,并使用
look_at()
调整其方向,使其投射出真实的阴影,并给场景一个适当的白天氛围。
让我们运行游戏,检查我们新更新的环境,看看现在是否足够好。
啊,太棒了!现在我们可以看到环境有一些很棒的阴影效果,这很酷,对吧?
到目前为止,我们已经编码了第一人称玩家的枪功能,以及一个更加愉快和逼真的环境。现在,我们可以开始编码敌人并为游戏添加更多功能,这样我们的AI代理就可以在这个更复杂的环境中采取适当的行动。
7、创建逻辑敌人和健康状态
现在我们非常接近完成我们的游戏环境,很快我们将进入代理阶段,其中AI将玩游戏。敌人是我们游戏中最重要的组成部分之一。
我们希望我们的敌人没有武器,而是他们的能力是通过移动来猎杀玩家并在接触时减少玩家的健康。
因此,首先,我们需要创建一个敌人结构,它不断尝试接近玩家,不管玩家移动到哪里。
class Enemy(Entity): # 敌人实体类
def __init__(self, **kwargs): # 初始化敌人
super().__init__(
parent=shootables_parent, # 设置父级为shootables
model='cube', # 使用立方体模型
scale_y=2, # 设置垂直尺度
origin_y=-.5, # 设置原点
color=color.light_gray, # 设置颜色
collider='box', # 设置碰撞器
**kwargs # 其他参数
)
self.health_bar = Entity( # 创建健康条实体
parent=self, # 设置父级为敌人
y=1.2, # 位于敌人上方
model='cube', # 使用立方体模型
color=color.red, # 设置颜色为红色
world_scale=(1.5, .1, .1) # 设置尺度
)
self.max_hp = 100 # 设置最大HP
self.hp = self.max_hp # 初始化HP
def update(self): # 每帧更新敌人
dist = distance_xz(player.position, self.position) # 计算距离到玩家
if dist > 40: # 如果太远则跳过
return
self.health_bar.alpha = max(0, self.health_bar.alpha - time.dt) # 淡化健康条
self.look_at_2d(player.position, 'y') # 面向玩家
hit_info = raycast( # 射线检测
self.world_position + Vec3(0, 1, 0), # 从敌人上方开始
self.forward, # 向前方向
30, # 射线长度
ignore=(self,) # 忽略自身
)
if hit_info.entity == player: # 如果击中玩家
if dist > 2: # 如果不靠近
self.position += self.forward * time.dt * 5 # 向玩家移动
@property
def hp(self): # HP属性获取器
return self._hp
@hp.setter
def hp(self, value): # HP属性设置器
self._hp = value # 设置HP
if value <= 0: # 如果HP耗尽
destroy(self) # 销毁敌人
return
self.health_bar.world_scale_x = self.hp / self.max_hp * 1.5 # 更新健康条尺度
self.health_bar.alpha = 1 # 显示健康条
我们的敌人代码中有很多事情发生,但让我们简化它以便更容易理解。我们的敌人是以一种简单但有效的方式创建和运作的。
以下是分解:
- 敌人是一个立方体实体,呈浅灰色,有盒子碰撞器,略微拉伸的垂直尺度以模仿一个人。
- 一个红色健康条附加在敌人上方以显示其当前健康状况。
- 敌人开始有100 HP,当HP达到0时,它会被销毁。
- 在
update
函数中,敌人首先检查它与玩家的距离,如果玩家超过40个单位远,它会不做任何事情以节省性能。 - 当未最近击中时,健康条会慢慢淡出以保持屏幕整洁。
- 敌人总是旋转以面对玩家使用
look_at_2d
。 - 使用射线检测来检测是否有清晰的视线到玩家。
- 如果检测到玩家且距离大于2个单位,敌人会朝玩家移动以自然地追逐玩家。
- HP属性根据剩余健康更新健康条的大小,并在被击中时短暂显示健康条。
- 当HP降到0或以下时,敌人被销毁并从游戏中移除。
这只是单个敌人。现在,我们可以通过简单地使用循环在环境中生成几个敌人。
# 创建多个敌人并沿x轴定位它们
enemies = []
for x in range(4): # 4个敌人的范围
enemy = Enemy(x=x * 4) # 每隔4个单位放置敌人
enemies.append(enemy) # 添加到敌人列表
因此,我们在游戏中总共创建了4个敌人。让我们运行游戏,看看这些敌人在环境中的行为和移动方式。
好的,所以我们的游戏正常工作,敌人试图接近我们,当玩家射击它们时,它们的健康条会减少。然而,即使敌人接近玩家,它们还没有影响玩家的健康。
因此,现在我们需要创建那个组件。第一步是创建一个玩家健康条组件。
# 为玩家创建一个健康条,设置其颜色和初始值
player_health_bar = HealthBar(
bar_color=color.lime.tint(-.25), # 健康条颜色
roundness=.5, # 圆角
value=player.hp, # 初始健康值
max_value=player.max_hp # 最大健康值
)
我们的玩家健康条类似于敌人的健康条,但有更多的细节,比如圆角和浅绿色颜色以区分它。
现在,我们已经编码了玩家健康条,我们可以简单地更新敌人循环。由于敌人类已经处理了敌人的健康,我们可以扩展它以在敌人接近或攻击玩家时监控并影响玩家的健康。
class Enemy(Entity):
def __init__(self, **kwargs):
...
def update(self):
...
self.health_bar.alpha = max(0, self.health_bar.alpha - time.dt) # 淡化健康条(第19行)
self.look_at_2d(player.position, 'y') # 面向玩家(第20行)
hit_info = raycast(
self.world_position + Vec3(0, 1, 0), # 从敌人头部射线检测(第21行)
self.forward, # 向前方向(第22行)
30, # 射线距离(第23行)
ignore=(self,) # 忽略自身(第24行)
)
if hit_info.entity == player: # 如果击中玩家(第25行)
if dist > 2: # 如果不靠近(第26行)
self.position += self.forward * time.dt * 5
else:
# 攻击逻辑(第27行)
if not self.attack_cooldown:
player.hp -= 20 # 减少玩家HP(第28行)
if player_health_bar: # 如果存在健康条,更新(第29行)
player_health_bar.value = player.hp
self.attack_cooldown = True # 设置冷却(第30行)
invoke(setattr, self, 'attack_cooldown', False, delay=1) # 1秒后重置冷却(第31行)
@property
...
@hp.setter
...
我们的玩家现在拥有类似敌人的健康系统,我们已更新敌人逻辑以在足够近时攻击玩家。以下是简单的步骤:
- 玩家健康条使用
HealthBar
创建,颜色为浅绿色(color.lime.tint(-.25)
),圆角,初始值设置为玩家当前和最大HP。 - 在敌人的更新循环中,我们复用了处理敌人行为的相同逻辑来管理攻击玩家。
- 敌人使用射线检测来检测玩家是否在其前方且在范围内。
- 如果玩家在2个单位之外,敌人会继续向玩家移动。
- 一旦敌人足够近(≤2个单位),它会进入攻击模式。
- 敌人检查它是否不在攻击冷却中以防止快速连续攻击。
- 当攻击时,敌人减少玩家的HP 20。
- 玩家健康条的值被更新以反映新的健康状况。
- 敌人设置攻击冷却为
True
,然后在1秒后将其重置为False
,因此它以自然的间隔攻击而不是连续施加伤害。
太好了!现在,我们已经赋予敌人快速接近玩家并减少玩家健康的能力,让我们测试我们的游戏以查看一切是否正常运行。
8、测试我们的游戏
到目前为止,我们已经构建了以下游戏组件:
- ✅ 草地地面,带有天空和阳光,使环境更加自然。
- ✅ 地面上的墙实体,使游戏更加有趣并提供藏身之处。
- ✅ 第一人称控制器,带有放置在相机前面的红色方块作为武器,可以射击并创建闪光效果。
- ✅ 敌人,快速向玩家移动并试图消灭他们。
- ✅ 玩家和敌人的健康条。
现在,让我们测试隐藏在墙后是否使我们不可被敌人发现。如果一切按预期工作,敌人应该在墙阻挡它们的路径时停止追捕我们,除非我们再次进入它们的视野。
所以,正如你所见,当我们躲在墙后时,我们的敌人无法找到我们。虽然我们可以进一步改进它们的逻辑,让它们绕过墙(使用旋转和路径寻找来寻找玩家),但目前,根据我们的预期,它工作得正确。
现在,让我们尝试通过消灭所有敌人来赢得游戏!
我们几乎在赢的时候失去了,因为敌人非常快,但游戏运行得非常好,一旦你赢了游戏就会出现打印消息,可以选择重新开始或退出游戏。
还有一件事要做,检查输掉功能是否正常工作,所以让我们的敌人尝试赢。
敌人失败阶段也完美运行,胜利消息按预期出现。
现在,我们可以继续开发游戏的核心基础设施,我们的AI代理,它将自动为我们玩游戏并最终赢得游戏。这才是真正令人兴奋的地方!
10、捕获游戏状态
如前所述,我们的代理将由两个组件/模型组成:
- 视觉模型来“看到”屏幕。
- 基于文本的推理模型来理解文本信息,如敌人位置、玩家位置、健康状态等。
为了使这工作,我们需要维护一个字典,存储游戏状态,其中包括代理做出决策并相应地采取下一步所需的所有必要文本信息。
首先,让我们捕获玩家的位置和瞄准方向。这将允许我们的代理直接瞄准屏幕上可见的敌人。我们将这些值存储在变量中,稍后可以添加到我们的游戏状态字典中。
import math # 导入数学模块用于数学函数。
# 获取玩家当前的y轴旋转(以度为单位)
player_y_rotation = player.rotation_y
# 计算从玩家到敌人的方向向量
direction_vector = enemy.position - player.position
# 使用atan2计算玩家到敌人的角度(以弧度为单位)
angle_to_enemy_rad = math.atan2(direction_vector.x, direction_vector.z)
# 将角度从弧度转换为度数
angle_to_enemy_deg = math.degrees(angle_to_enemy_rad)
# 计算玩家的视图角度和到敌人的角度之间的差异
aiming_error = angle_to_enemy_deg - player_y_rotation
# 将瞄准误差归一化为-180到180度之间以便于解释
if aiming_error > 180:
aiming_error -= 360
if aiming_error < -180:
aiming_error += 360
我们基本上是通过比较玩家的当前视图角度和敌人的位置来计算玩家瞄准敌人的准确性。
- 我们首先获取玩家的y轴旋转(水平视图方向)。
- 然后我们找到从玩家到敌人的方向向量,通过减去它们的位置。
- 使用
math.atan2
,我们计算精确的角度(以弧度为单位)从玩家到敌人。 - 这个角度被转换为度数以便于解释。
- 我们通过从敌人角度中减去玩家的当前视图角度来计算瞄准误差。
- 最后,我们将瞄准误差归一化以保持在**-180到180度**之间,使代理更容易解释是否需要左右瞄准以瞄准敌人。
接下来,基于这个计算和Ursina引擎的默认信息,我们需要确定击中信息,在游戏进行时。具体来说,我们需要知道一次射击是否击中敌人或错过。
为此,我们可以使用raycast
元素,我们之前已经用于构建一些实体。射线检测将从玩家的武器(或相机方向)投射一条线,并检测它是否与敌人实体碰撞。
这个击中信息将被存储在我们的游戏状态字典中,这样代理可以理解它的射击动作是否成功。
# 使用从玩家相机出发的射线检测来检查敌人是否可见。
hit_info = raycast(player.world_position + player.camera_pivot.up,
camera.forward, distance=100, ignore=(player,))
# 如果射线检测击中敌人实体,则敌人可见。
is_enemy_visible = True if hit_info.entity == enemy else False
这样,我们确定敌人是否直接可见于玩家或被任何障碍物阻挡。
- 我们使用一个射线检测,从玩家的相机位置开始,稍微向上调整(
player.camera_pivot.up
)。 - 射线检测沿着相机面对的方向(
camera.forward
)发射。 - 我们设置最大距离为100单位,足以覆盖远距离的可见性。
- 射线检测忽略玩家本身以防止错误检测。
- 如果射线检测直接击中敌人实体,我们将
is_enemy_visible
设为True
,否则为False
。
这个信息将帮助我们的代理决定:
- 如果敌人在视野内且命中检测为正 → 瞄准并射击。
- 如果敌人不在视野内 → 移动靠近或调整位置直到敌人变得可见。现在,我们可以将所有相关信息(如玩家位置、瞄准方向、击中检测、敌人位置和健康状态)存储在一个字典中。
# 导入json模块
import json
# 创建一个字典来保存所有相关的游戏状态数据。
game_data = {
"player_health": player.hp, # 玩家当前的健康值
"player_rotation_y": player_y_rotation, # 玩家的Y轴旋转
"enemy_health": enemy.hp, # 敌人的当前健康值
"distance_to_enemy": distance_xz(player.position, enemy.position), # 距离敌人
"is_enemy_visible": is_enemy_visible, # 敌人是否可见
"angle_to_enemy_error": aiming_error, # 瞄准敌人的误差角度
"game_status": game_state, # 当前游戏状态
}
# 将游戏状态数据写入JSON文件。
with open("game_state.json", "w") as f:
json.dump(game_data, f)
我们将关键的重要细节从我们的游戏状态存储到一个结构化的字典中,这样我们的代理(DeepSeek模型)可以做出更好的决策。
player_health
存储玩家的当前HP,以决定防御或进攻动作。player_rotation_y
记录玩家的视角方向,以帮助瞄准决策。enemy_health
跟踪敌人的HP,以决定是否继续攻击或切换目标。distance_to_enemy
计算敌人有多远,有助于决定是靠近还是射击。is_enemy_visible
表示敌人是否在玩家的视线范围内。angle_to_enemy_error
测量玩家的瞄准需要调整多少才能瞄准敌人。game_status
存储当前的游戏阶段或状态(例如,战斗、探索、暂停)。
我们还需要一个函数来加载我们的游戏开始JSON信息。让我们也编写这个函数。
def read_game_state():
"""从JSON文件中读取当前游戏状态。"""
with open("game_state.json", "r") as f:
return json.load(f)
太好了!现在我们可以编写代理的一半大脑——负责通过DeepSeek理解文本信息的部分。
让我们继续编写这部分。
11、构建动作决策组件
我们需要根据之前存储在JSON文件中的环境状态,让我们的代理能够采取行动。为了采取行动,我们必须给我们的DeepSeek组件提供对键盘和鼠标的访问权限。
因此,我们首先导入这些模块并正确初始化它们。
# 从pynput导入键盘控制类
from pynput.keyboard import Key, Controller as KeyboardController
# 从pynput导入鼠标控制类
from pynput.mouse import Button, Controller as MouseController
# 创建一个键盘控制器实例,模拟按键
keyboard = KeyboardController()
# 创建一个鼠标控制器实例,模拟鼠标移动和点击
mouse = MouseController()
# 设置“中尉”代理的模型ID,这是一个快速的纯文本模型,用于快速战术决策。
text_model_id = "deepseek-ai/DeepSeek-V3"
我们正在通过这些基本组件初始化代理,使其能够控制鼠标和键盘。
接下来,我们需要为我们的代理初始化执行策略,基本上定义代理可以执行的可能动作。为此,我们也需要创建一个函数。
def execute_command(command, game_state):
"""(肌肉) 将AI的低级命令转换为实际的键盘和鼠标操作。"""
# 从游戏状态中获取当前的瞄准误差,默认为0如果不可用。
aim_error = game_state.get('angle_to_enemy_error', 0)
# 如果命令是AIM或ATTACK并且敌人当前可见...
if command in ['AIM', 'ATTACK'] and game_state.get('is_enemy_visible', False):
# 计算需要移动的鼠标距离以校正瞄准。
mouse_movement = -int(aim_error * 2.5) # 乘数作为灵敏度。
# 水平移动鼠标以调整瞄准。
mouse.move(mouse_movement, 0)
# 如果命令是ATTACK且瞄准已经准确(误差很小)...
if command == "ATTACK" and abs(aim_error) < 5:
# 按下左键以射击。
mouse.press(Button.left)
else:
# 否则,释放左键以停止射击。
mouse.release(Button.left)
# 如果命令是进行防御动作...
if command == "DEFENSIVE_MANEUVER":
# 按下's'和'a'键以向后和向左移动。
keyboard.press('s'); keyboard.press('a')
# 持续一段时间。
time.sleep(0.5)
# 释放按键。
keyboard.release('s'); keyboard.release('a')
# 如果命令是搜索敌人...
if command == "SEARCH":
# 水平移动鼠标以四处查看。
mouse.move(80, 0)
# 如果命令是前进...
if command == "ADVANCE":
# 按下'w'键以短暂向前移动。
keyboard.press('w'); time.sleep(0.3); keyboard.release('w')
我们正在定义代理可以执行的五个可能动作,基于存储在JSON文件中的游戏状态:
- AIM → 根据瞄准误差水平移动鼠标以调整瞄准。
- ATTACK → 在瞄准准确时按住左键射击。
- DEFENSIVE_MANEUVER → 按下's'和'a'键以向后和向左移动,以躲避或寻找掩护。
- SEARCH → 移动鼠标以四处查看,当敌人不可见时寻找敌人。
- ADVANCE → 短暂按下'w'键以向前移动,接近敌人或目标。
这些动作直接通过Pynput控制器操作鼠标和键盘,使DeepSeek代理能够像人类玩家一样与游戏环境互动。
12、创建DeepSeek V3子代理组件
现在,我们需要实现DeepSeek组件的重要部分,即决定采取什么行动的部分。这种决策只能通过LLM完成,为此我们还需要创建一个提示模板。
让我们接下来做这件事。
def get_tactical_action_from_llm(strategy, game_state):
"""(中尉) 使用纯文本模型选择基于指挥官策略的即时行动。"""
# 将游戏状态字典转换为JSON字符串。
state_report = json.dumps(game_state, indent=2)
# 向OpenAI API发送请求,包含当前策略和游戏状态。
response = client.chat.completions.create(
model=text_model_id, # 使用指定的快速文本模型。
messages=[
{
"role": "system",
"content": f"""
你是一个战术AI中尉。你的指挥官下达了战略指令:'{strategy}'。
你的任务是根据此指令和实时数据选择最佳的即时行动。
如果战略是“积极进攻”:
- 如果敌人可见且瞄准良好(误差 < 5),命令`ATTACK`。
- 如果敌人可见但瞄准不好,命令`AIM`。
- 如果敌人很远(>15米),命令`ADVANCE`。
如果战略是“防御性重新定位”:
- 命令`DEFENSIVE_MANEUVER`立即进入安全区域。
如果战略是“猎杀敌人”:
- 如果敌人不可见,命令`SEARCH`。如果他们突然变得可见,命令`AIM`。
选择一个命令:`ATTACK`、`AIM`、`ADVANCE`、`DEFENSIVE_MANEUVER`、`SEARCH`。
"""
},
{"role": "user", "content": f"Strategy: '{strategy}'.\nReal-time data:\n{state_report}\n\nYour command:"}
],
max_tokens=10, # 限制响应长度。
temperature=0.0 # 设置温度为0以获得确定性的非创造性响应。
)
# 从AI的响应中提取动作命令。
action = response.choices[0].message.content.strip().replace("'", "").replace('"', "")
print(f"中尉针对'{strategy}'的行动: {action}")
# 返回选择的动作。
return action
我们的战术动作函数在这里非常重要,因为它将根据当前的战略和实时游戏状态决定采取什么动作。
它检查条件并选择一个明确的命令,如AIM
、ATTACK
、ADVANCE
、DEFENSIVE_MANEUVER
或SEARCH
。这样,我们的代理就能智能而迅速地应对游戏中的任何情况。
我们可以在后面编写的主逻辑组件中使用这个函数,让我们继续编写代理的另一个组件,该组件帮助代理看到环境。
13、让Mistral 3.1代理看到屏幕
到目前为止,我们已经构建了代理的一半,它可以通过文本信息读取游戏。但为了使其更加可靠,我们需要使代理能够看到屏幕,这可以通过**视觉语言模型(VLM)**实现。这也会显著提高模型的性能。
首先,我们需要一个工具,可以快速捕获屏幕,几乎没有延迟。为此,我们将使用Python内置的模块mss
。
让我们接下来做这件事。
import base64 # 用于将截图图像编码为文本格式以供API使用。
import mss # 一个快速的截图工具。
import mss.tools # mss库的附加工具,如保存图像。
# 设置“指挥官”代理的模型ID,它使用视觉能力进行高层战略。
vision_model_id = "mistralai/Mistral-Small-3.1-24B-Instruct-2503"
# 初始化代理的当前战略目标。它从“INITIALIZING”状态开始。
current_strategic_goal = "INITIALIZING"
# 跟踪上次更新高层战略的时间。初始化为0。
last_strategic_update_time = 0
# 设置“指挥官”(视觉模型)重新评估战略的间隔时间(以秒为单位)。
strategic_update_interval = 1.0 # 指挥官每1秒思考一次
我们只是定义了我们的模型,并设置了我们的视觉“指挥官”代理的基本变量。我们指定了视觉模型ID(mistralai/Mistral-Small-3.1-24B-Instruct-2503
),将当前战略目标初始化为“INITIALIZING”,并跟踪战略最后一次更新的时间。
我们还设置了一个1秒的间隔,意味着指挥官每隔1秒会根据它“看到”的屏幕内容重新评估整体战略。
接下来,我们需要编写一个函数,可以每秒捕获屏幕并将它传递给我们的视觉模型进行处理。让我们实现这个函数。
def capture_screen_as_base64():
"""捕获整个屏幕并将其返回为base64编码的字符串。"""
# 使用mss库作为上下文管理器以高效处理资源。
with mss.mss() as sct:
# 获取主显示器的信息。
monitor = sct.monitors[1] # 假设主显示器是1
# 从显示器中抓取图像数据。
sct_img = sct.grab(monitor)
# 将原始图像数据(RGB)转换为PNG格式的字节。
img_bytes = mss.tools.to_png(sct_img.rgb, sct_img.size)
# 将PNG字节编码为base64字符串并解码为标准UTF-8字符串。
return base64.b64encode(img_bytes).decode('utf-8')
这是一个辅助函数,每次调用时都会捕获全屏截图并将其转换为base64字符串。
我们使用mss
进行快速屏幕捕捉,抓取主显示器,将原始RGB数据转换为PNG字节,然后对其进行base64编码,以便轻松发送到我们的视觉模型进行分析。
类似于我们的文本DeepSeek代理,我们还需要一个基于多模态的函数,它建立在我们的视觉提示模板之上,将再进行一步思考,与文本代理一起决定采取什么行动。所以让我们也编写这个函数。
def get_strategic_goal_from_vlm(game_state, screenshot_base64):
"""(指挥官) 使用视觉模型和游戏数据来决定高层战略。"""
print("\n--- 指挥官正在思考(VLM)... ---")
# 将游戏状态字典转换为格式良好的JSON字符串用于提示。
state_report = json.dumps(game_state, indent=2)
# 向OpenAI API发送请求,包含系统提示、用户文本和截图。
response = client.chat.completions.create(
model=vision_model_id, # 使用指定的视觉模型。
messages=[
{
"role": "system",
"content": """
你是一个战略AI指挥官。你通过图像和精确的数据看到大局。你的任务是设定总体战略,而不是立即行动。
分析视觉环境和数据报告。
选择以下其中一个战略目标:
- `ENGAGE_AGGRESSIVELY`: 情况有利。我有良好的健康状况,敌人处于可击杀的位置。
- `REPOSITION_DEFENSIVELY`: 情况危险。我健康状况不佳,处于不利位置(过于开放、过于接近),或者刚刚受到伤害。生存是关键。
- `HUNT_THE_ENEMY`: 我看不到敌人。我的目标是找到他们。
仅提供所选战略的命令词。
"""
},
{
"role": "user",
"content": [
# 用户提示包括文本数据和图像。
{"type": "text", "text": f"分析场景和此数据以设定战略。\nDATA:\n{state_report}"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{screenshot_base64}"}}
]
}
],
max_tokens=10 # 限制响应长度以仅获取命令。
)
# 从AI的响应中提取战略文本。
strategy = response.choices[0].message.content.strip().replace("'", "").replace('"', "")
print(f"--- 指挥官的新战略: {strategy} ---")
# 返回选择的战略。
return strategy
我们的函数get_strategic_goal_from_vlm()
作为代理的**“指挥官”大脑**,它接收截图(视觉输入)和游戏状态(文本输入),以决定高层战略。以下是简化说明:
- 将游戏状态转换为JSON文本 → 使其对视觉语言模型(VLM)可读。
- 将文本和图像发送给VLM → 结合原始环境数据和实际视觉线索以进行更好的战略推理。
- 使用预定义的系统提示 → 告诉模型只选择三种战略之一:
ENGAGE_AGGRESSIVELY
→ 在条件有利时攻击。REPOSITION_DEFENSIVELY
→ 在危险时采取掩护或撤退。HUNT_THE_ENEMY
→ 如果无法看到敌人,则移动/搜索。- 提取并清理响应 → 返回仅战略单词以供进一步决策。
现在,我们已经编写了我们的AI代理的两个组件:
- ✅ 基于文本的推理(DeepSeek与LLM)
- ✅ 视觉感知(屏幕捕捉用于视觉模型)
我们可以继续编写AI代理的主要逻辑,它将整合这些组件以在游戏环境中进行实时决策。
让我们继续编写主要逻辑。
14、创建我们的AI代理的主要动作逻辑
我们需要创建一个循环,它会不断应用我们之前编写的逻辑函数。这个循环还应该存储每个动作状态,允许代理根据其过去的知识决定下一步是否不同或保持不变。让我们编写这个循环逻辑。
# 导入必要的库
import time
# 开始持续运行的主控制循环。
while True:
# 从JSON文件中读取最新的游戏状态。
game_state = read_game_state()
# 如果游戏状态缺失或表示游戏已结束,停止代理。
if not game_state or game_state.get('game_status') in ['won', 'lost']:
print(f"游戏结束或状态不可读。")
break
# --- 指挥官的回合(战略思考) ---
# 检查自上次战略更新以来是否已过去足够的时间。
if time.time() - last_strategic_update_time > strategic_update_interval:
# 如果是,捕获新的截图。
screenshot = capture_screen_as_base64()
# 询问指挥官(VLM)一个新的战略目标。
current_strategic_goal = get_strategic_goal_from_vlm(game_state, screenshot)
# 更新上次战略更新的时间戳。
last_strategic_update_time = time.time()
# --- 中尉的回合(战术执行) ---
# 确保指挥官已经提供了初始战略。
if current_strategic_goal != "INITIALIZING":
# 询问中尉(LLM)根据当前战略选择特定的战术动作。
tactical_action = get_tactical_action_from_llm(current_strategic_goal, game_state)
# 执行选定的动作。
execute_command(tactical_action, game_state)
else:
# 如果仍在初始化,只需打印等待消息。
print("等待指挥官的初始战略...")
# 在下一个循环迭代之前短暂停顿,以控制动作频率。
time.sleep(0.3) # 战术循环速度
它首先在循环中持续运行,直到游戏结束或状态不可读。
- 它首先从JSON文件中读取最新的游戏状态,以确保信息是最新的。
- 如果游戏结束(无论是胜利还是失败),循环立即终止。
- **指挥官(战略模型)**负责做出高层决策。每隔几秒(基于
strategic_update_interval
),它: - 捕获新的屏幕截图。
- 调用VLM(视觉语言模型)根据游戏状态和视觉输入获取新的战略目标。
- 更新上次战略决策的时间戳。
- **中尉(战术模型)**处理即时动作。
- 如果指挥官已经设定了有效的战略(不是“INITIALIZING”),中尉会向LLM(语言模型)询问下一个战术动作。
- 该动作通过
execute_command()
立即执行在游戏内。 - 如果仍在初始化,它只是等待并打印一条消息。
- **短暂的延迟(
time.sleep(0.3)
)**确保循环平稳运行,不会过度占用系统资源,为处理动作留出时间。
这个循环使代理能够做出动态决策,指挥官在较长的间隔内更新战略,而中尉根据实时游戏状态执行快速动作。
15、测试我们的AI代理
太好了!到目前为止,我们已经编写了我们的AI代理的每个组件。这里是对我们已完成工作的简要总结:
- 我们创建了一个3D游戏世界,里面有天空、太阳和几个方块。
- 然后我们创建了一个第一人称控制器,配有枪支,可以开火,带有闪光效果。
- 然后我们创建了敌人,它们可以通过接近玩家来终止玩家。
接下来,我们转向创建我们的代理组件:
- 我们首先创建了一个基于DeepSeek V3的文本代理,可以根据游戏信息采取行动。
- 为了支持我们的文本代理,我们还创建了一个视觉组件,帮助文本代理根据屏幕上看到的内容采取行动。
我们的目的是简单:在不失去全部健康的情况下消灭所有敌人。
让我们运行代码,看看我们的代理如何玩游戏。
为了简单起见,我们现在只使用一个敌人。在第一次运行中,代理尝试沿着正确的路径走,但未能完全杀死敌人并失败了。
我观察到敌人的速度相当快,所以让我们降低敌人的速度,然后再次测试我们的代理。
好的,所以在将敌人的速度降低到原来的一半之后,它给了我们的代理足够的时间去思考并采取适当的步骤。这非常酷,我们的代理现在可以成功地玩我们构建的基本3D游戏。
16、总结一切和未来步骤
让我们回顾一下我们迄今为止所做的工作:
- 我们创建了一个3D第一人称射击游戏,目标是消灭敌人(一个白色方块),而不要碰到它,因为接触会减少玩家的健康。
- 为了读取环境,我们将游戏环境输出捕获到一个JSON文件中,并创建了一个基于LLM的文本代理组件来相应地采取行动。
- 为了支持我们的文本代理,我们还创建了一个视觉组件,它捕获游戏屏幕并帮助文本代理改进其动作。
我们可以进一步使环境更加动态,添加树木、汽车和其他障碍物以增加代理的挑战。另一个重要的改进是包括并行处理LLM响应,这样即使敌人移动得很快,我们也可以消灭它。
原文链接:Building an AI Agent to Play a 3D FPS Game Using Ursina, DeepSeek, and Mistral
汇智网翻译整理,转载请标明出处