1. 项目概述:为什么要在Unity里训练一个会学习的Boss?
如果你是一个游戏开发者,尤其是独立开发者或者小团队,可能都面临过类似的困境:设计一个有趣、有挑战性但又不会让玩家感到沮丧的Boss战,需要投入大量的时间和精力去手动调整行为树、状态机或者一堆if-else逻辑。更头疼的是,一旦玩家找到了固定的“套路”,这个Boss就变得索然无味。传统的脚本化AI,其行为边界是开发者预设的,缺乏真正的“灵性”和“成长性”。
这就是为什么我们要把目光投向Unity ML-Agents Toolkit。它不是一个简单的插件,而是一个桥梁,将Unity这个强大的实时3D创作平台,与主流的机器学习框架(如PyTorch)连接起来。简单来说,它允许你直接在Unity编辑器里,通过“试错”的方式,让一个虚拟角色(比如我们的Boss)自己学会如何战斗。这个学习过程,就是强化学习。
想象一下,你不再需要告诉Boss“当玩家距离小于5米时,有30%概率使用冲锋技能”。你只需要定义好Boss能做什么动作(移动、攻击、防御、释放技能),以及什么是“好”什么是“坏”(比如击中玩家得正分,被玩家击中得负分),然后把它扔进一个模拟环境里,让它和成千上万个“玩家”克隆体对战。通过数百万次的尝试,Boss会自己摸索出一套最优的战斗策略,这套策略可能比你手动设计的更狡猾、更难以预测,甚至能发展出一些你意想不到的“骚操作”。
这次我们要做的,就是打造这样一个Boss。它不是一个只会按脚本走位的木桩,而是一个能观察战场形势(玩家位置、自身血量、技能冷却)、能决策(是逼近还是周旋?用普攻还是蓄力大招?)、并能从失败中学习的对手。我会附上完整的训练代码和Unity工程关键部分,让你能亲手复现并训练属于你自己的“AI Boss”。
2. 核心设计:拆解一个可学习的Boss需要哪些模块?
在动手写代码之前,我们必须把整个系统拆解清楚。一个基于ML-Agents的AI智能体,其核心是一个“感知-决策-执行”的循环。对于我们的Boss战场景,需要精心设计每一个环节。
2.1 观察空间设计:Boss的“眼睛”和“感觉”
观察空间是AI了解世界状态的窗口。我们不能把整个游戏世界的原始数据(比如所有顶点的位置)都扔给AI,那会带来“维度灾难”,导致学习无法进行。我们需要提取高层次、有意义的特征。
对于Boss战,我设计了以下几个维度的观察值:
相对位置与朝向:
- Boss自身到玩家的归一化向量(X, Y, Z)。归一化到[-1, 1]区间,避免绝对距离值过大影响训练。
- Boss面向方向与指向玩家方向的点积。这个值能告诉AI“我是否正面对着玩家”,对于判断是否需要转身很有用。
- 玩家相对于Boss的局部空间坐标(前、右、上方向)。这比世界坐标更直观。
自身状态:
- 当前生命值百分比(0到1)。
- 各个技能的冷却状态(0表示可用,1表示冷却中)。可以设计多个技能,如普攻、重击、冲锋、范围AOE。
- 当前是否处于某个特殊状态(如蓄力、防御)的布尔值编码。
环境与历史信息(可选但推荐):
- 最近几次攻击是否命中的历史记录(一个固定长度的队列,如最近5次,用0/1表示)。这能帮助AI学习攻击节奏和预判。
- 与玩家的最近距离变化率(是正在接近还是远离)。
将这些观察值拼接成一个一维浮点数数组,就构成了Boss的观察向量。设计原则是:尽可能提供与决策强相关的信息,且保持低维度。一开始可以简单些,后续再根据AI的表现增加。
注意:ML-Agents支持三种观察类型:
Vector(向量)、Raycast(射线感知)和Camera(视觉)。对于Boss战,Vector观察通常就够了,因为它速度快且稳定。Raycast可以用来感知墙壁或障碍物,如果你的战场有复杂地形可以考虑加入。
2.2 动作空间设计:Boss的“手脚”
动作空间定义了AI能做什么。ML-Agents支持离散动作、连续动作以及两者混合。
对于Boss,我采用混合动作空间:
- 离散动作:用于触发技能。例如:
- 分支0:无技能、普攻、重击、冲锋、范围AOE。
- 分支1:开启/关闭防御状态。
- (可以设计多个分支对应不同类型的技能组)
- 连续动作:用于控制移动。例如:
Vector2控制平面移动(前/后,左/右)。float控制转向速度(-1到1,表示向左转或向右转的强度)。
这样设计既能让AI灵活地走位,又能精确地触发关键技能。切忌给AI太多无效或矛盾的动作选项,比如同时允许向前和向后移动,这会让AI困惑。
2.3 奖励函数设计:引导Boss学习的“胡萝卜与大棒”
奖励函数是强化学习的灵魂,直接决定了AI会学成什么样。我们的目标是让Boss学会“有效且有趣地战斗”,而不是简单地“秒杀玩家”或“苟活”。
核心正向奖励(胡萝卜):
- 对玩家造成伤害:每次成功击中玩家,给予一个正奖励(如+0.3)。可以根据伤害值进行缩放。
- 成功格挡或闪避:如果设计了防御机制,成功防御玩家攻击时给予奖励(如+0.1)。
- 保持威胁距离:当Boss与玩家保持在一个理想的战斗距离(比如3-8米)时,每步给予一个微小的持续奖励(如+0.001)。这能鼓励Boss不要离得太远或贴得太近。
- 技能连招奖励:如果AI连续使用了两个有配合效果的技能(如冲锋击晕后接重击),给予额外奖励。
核心负向奖励(大棒):
- 受到玩家伤害:每次被玩家击中,给予负奖励(如-0.2)。
- 技能打空:释放技能但未命中玩家,给予小额负奖励(如-0.05),避免AI胡乱放技能。
- 长时间无交互:如果超过一定时间(如200步)既未击中玩家也未被打中,给予一个较大的负奖励(如-0.5),鼓励积极交战。
- 生命值过低惩罚:当生命值低于一定阈值时,每一步都给予小额负奖励,模拟“危机感”。
回合结束奖励(终局奖惩):
- 击败玩家:+5.0 (巨大成功)
- 被玩家击败:-3.0 (失败)
一个关键技巧:奖励塑造。一开始训练时,可以把奖励设置得“稠密”一些(比如每步都有小奖励),帮助AI快速建立初步概念。等AI学会基础操作后,再逐渐将奖励“稀疏化”,更多地依赖回合结束的胜负奖励,让AI学习更长远的策略。
2.4 训练环境配置:创造一个高效的“训练场”
单个Boss和玩家对练效率太低。我们需要利用ML-Agents的并行训练功能。
- 场景复制:在同一个Unity场景中,创建多个(比如20-50个)完全相同的训练区域副本。每个区域都有一个Boss和一个玩家。
- 玩家控制:训练初期,玩家可以由另一个简单的脚本AI控制(比如随机移动和攻击)。这比用真人或复杂AI高效得多。后期为了提升Boss的对抗性,可以换用更高级的脚本AI甚至另一个训练过的AI作为对手。
- 课程学习:这是高级技巧。可以设计一个“课程”,随着Boss表现变好,逐步增加难度。例如:
- 阶段一:玩家AI静止不动,让Boss学习基本的移动和攻击。
- 阶段二:玩家AI会简单移动和闪避。
- 阶段三:玩家AI会使用技能和走位。
- 阶段四:提高玩家AI的属性(血量、攻击力)。 通过动态调整这些参数,可以引导Boss平稳地学习到高难度技巧。
3. 实战搭建:从零构建Unity ML-Agents Boss训练工程
理论说完了,我们进入实战环节。请确保你已安装Unity(建议2021 LTS或更新版本)和Python环境(用于运行训练脚本)。
3.1 环境准备与ML-Agents安装
首先,在Unity中创建一个新项目(3D Core模板即可)。
- 安装ML-Agents包:打开Package Manager,选择“Unity Registry”,搜索“ML-Agents”并安装。或者,对于更前沿的特性,可以从Git URL添加:
https://github.com/Unity-Technologies/ml-agents.git?path=com.unity.ml-agents。 - 安装Python依赖:打开命令行,创建一个虚拟环境(推荐),然后安装:
这将会安装pip install mlagentsmlagents命令行工具和相关的PyTorch等依赖。
3.2 Boss智能体脚本编写
在Unity中创建一个C#脚本,命名为BossAgent,并让它继承自Agent类。这是核心。
using UnityEngine; using Unity.MLAgents; using Unity.MLAgents.Actuators; using Unity.MLAgents.Sensors; public class BossAgent : Agent { // 引用 private Rigidbody rb; private PlayerController player; // 假设有一个PlayerController脚本在玩家身上 private BossCombat combat; // Boss自身的战斗逻辑脚本 // 参数 public float moveSpeed = 5f; public float rotateSpeed = 180f; public float idealCombatDistance = 5f; public float distanceTolerance = 2f; // 初始化 public override void Initialize() { rb = GetComponent<Rigidbody>(); combat = GetComponent<BossCombat>(); // 玩家引用需要在场景初始化时赋值,或通过Tag查找 player = GameObject.FindGameObjectWithTag("Player").GetComponent<PlayerController>(); } // 收集观察值 public override void CollectObservations(VectorSensor sensor) { if (player == null) return; // 1. 相对位置与朝向 Vector3 toPlayer = player.transform.position - transform.position; sensor.AddObservation(toPlayer.normalized); // 3个值 (x, y, z) float dot = Vector3.Dot(transform.forward, toPlayer.normalized); sensor.AddObservation(dot); // 1个值 Vector3 localPlayerPos = transform.InverseTransformPoint(player.transform.position); sensor.AddObservation(localPlayerPos.normalized); // 3个值 // 2. 自身状态 sensor.AddObservation(combat.CurrentHealthPercent); // 1个值 sensor.AddObservation(combat.IsSkillOnCooldown(0)); // 普攻冷却 sensor.AddObservation(combat.IsSkillOnCooldown(1)); // 重击冷却 sensor.AddObservation(combat.IsSkillOnCooldown(2)); // 冲锋冷却 // ... 其他技能状态 // 3. 历史信息示例(最近3次攻击命中) foreach (bool hit in combat.recentHitHistory) // 假设这是一个List<bool> { sensor.AddObservation(hit ? 1.0f : 0.0f); } // 总观察值维度 = 3+1+3+1+3+3 = 14 (示例,根据实际情况增减) } // 执行动作 public override void OnActionReceived(ActionBuffers actions) { // 处理连续动作:移动和转向 Vector2 moveInput = new Vector2(actions.ContinuousActions[0], actions.ContinuousActions[1]); float rotateInput = actions.ContinuousActions[2]; // 移动 Vector3 moveDirection = new Vector3(moveInput.x, 0, moveInput.y).normalized; Vector3 worldMove = transform.TransformDirection(moveDirection) * moveSpeed * Time.deltaTime; rb.MovePosition(rb.position + worldMove); // 转向 float rotation = rotateInput * rotateSpeed * Time.deltaTime; Quaternion deltaRotation = Quaternion.Euler(0f, rotation, 0f); rb.MoveRotation(rb.rotation * deltaRotation); // 处理离散动作:释放技能 int skillToUse = actions.DiscreteActions[0]; bool defend = actions.DiscreteActions[1] == 1; combat.ExecuteSkill(skillToUse); // 这个方法会处理技能释放逻辑和冷却 combat.SetDefend(defend); // 奖励塑造:保持理想距离 float distanceToPlayer = Vector3.Distance(transform.position, player.transform.position); float distanceReward = Mathf.Exp(-Mathf.Pow(distanceToPlayer - idealCombatDistance, 2) / (2 * distanceTolerance * distanceTolerance)); AddReward(distanceReward * 0.001f); // 非常小的每步奖励 } // 回合开始(重置) public override void OnEpisodeBegin() { // 重置Boss状态 combat.ResetHealth(); combat.ResetAllCooldowns(); // 重置位置(可以在一个范围内随机) transform.localPosition = new Vector3(Random.Range(-5f, 5f), 0, Random.Range(-5f, 5f)); transform.localRotation = Quaternion.identity; // 重置玩家(如果玩家也是Agent或需要重置) if (player != null) { player.ResetPlayer(); } } // 外部调用:当Boss击中玩家时 public void OnHitPlayer(float damageDealt) { AddReward(0.3f + damageDealt * 0.01f); // 基础奖励+伤害比例奖励 } // 外部调用:当Boss被玩家击中时 public void OnGetHit(float damageTaken) { AddReward(-0.2f - damageTaken * 0.01f); } // 外部调用:当Boss击败玩家 public void OnPlayerDefeated() { AddReward(5.0f); EndEpisode(); // 结束本轮训练 } // 外部调用:当Boss被击败 public void OnSelfDefeated() { AddReward(-3.0f); EndEpisode(); } }3.3 配套组件脚本
你需要编写BossCombat和PlayerController(或PlayerAgent)脚本。这里给出BossCombat的简化框架:
public class BossCombat : MonoBehaviour { public float maxHealth = 1000f; private float currentHealth; public List<float> skillCooldowns; // 技能冷却时间列表 private float[] skillCooldownTimers; public List<bool> recentHitHistory; // 最近命中记录 public float CurrentHealthPercent => currentHealth / maxHealth; void Start() { currentHealth = maxHealth; skillCooldownTimers = new float[skillCooldowns.Count]; recentHitHistory = new List<bool>() { false, false, false }; } void Update() { // 更新技能冷却 for (int i = 0; i < skillCooldownTimers.Length; i++) { if (skillCooldownTimers[i] > 0) skillCooldownTimers[i] -= Time.deltaTime; } } public bool IsSkillOnCooldown(int skillIndex) { return skillCooldownTimers[skillIndex] > 0; } public void ExecuteSkill(int skillIndex) { if (skillIndex == 0 || IsSkillOnCooldown(skillIndex)) return; // 0代表“无技能” // 触发技能动画、碰撞体、伤害计算等 Debug.Log($"Boss 使用技能 {skillIndex}"); // 假设这里通过动画事件或触发器来实际造成伤害,并调用BossAgent的OnHitPlayer // 技能释放后进入冷却 skillCooldownTimers[skillIndex] = skillCooldowns[skillIndex]; } public void TakeDamage(float amount) { currentHealth -= amount; if (currentHealth <= 0) { currentHealth = 0; GetComponent<BossAgent>()?.OnSelfDefeated(); } } public void RecordHit(bool hitSuccess) { recentHitHistory.RemoveAt(0); recentHitHistory.Add(hitSuccess); } // ... 其他重置方法 }3.4 配置训练参数文件
在项目根目录创建一个config文件夹,里面新建一个YAML文件,例如boss_trainer_config.yaml。这是控制训练过程的大脑。
behaviors: BossBehavior: # 这个名称需要和Unity中Behavior Parameters组件的Behavior Name一致 trainer_type: ppo # 使用PPO算法,最常用且稳定 hyperparameters: batch_size: 1024 buffer_size: 10240 learning_rate: 3.0e-4 beta: 5.0e-3 # 熵系数,鼓励探索 epsilon: 0.2 lambd: 0.95 num_epoch: 3 learning_rate_schedule: linear network_settings: normalize: true hidden_units: 128 num_layers: 2 reward_signals: extrinsic: gamma: 0.99 # 折扣因子,越接近1越考虑长远回报 strength: 1.0 max_steps: 5.0e6 # 最大训练步数 time_horizon: 64 # 每次更新前收集的步数 summary_freq: 10000 # 每多少步输出一次总结日志3.5 启动训练
- 在Unity中,为Boss的GameObject添加
Behavior Parameters组件,设置Behavior Name为BossBehavior,并根据脚本定义好Vector Observation的Space Size(我们之前算的14),以及Actions的Continuous和Discrete分支。 - 添加
Decision Requester组件,设置Decision Period为5,意思是每5个Unity帧请求一次决策(平衡性能与响应速度)。 - 构建并运行场景,确保多个训练区域能正常工作。
- 在命令行中,进入到项目根目录,运行训练命令:
mlagents-learn config/boss_trainer_config.yaml --run-id=boss_v1 --env=builds/YourGameBuild --num-envs=20 --force--run-id: 本次训练运行的标识符,用于区分不同实验。--env: 指向你构建的可执行文件路径。如果直接在Editor中训练,可以不加此参数,使用--env=null。--num-envs: 并行环境数量,与你Unity场景中复制的区域数对应。--force: 如果已有同名run-id的模型,则覆盖。
训练开始后,你会看到命令行窗口输出每一步的奖励和进度。同时,可以使用TensorBoard来可视化训练过程:
tensorboard --logdir results4. 训练调优与问题排查实录
训练AI很少能一帆风顺。下面是我在多次实践中总结的常见问题与解决思路。
4.1 问题:奖励不增长,智能体“摆烂”
- 现象:累计奖励曲线几乎是一条零附近的水平线,Boss要么不动,要么重复无意义的动作。
- 排查与解决:
- 检查奖励函数:这是最常见的原因。是否正负奖励抵消严重?比如击中玩家得+0.1,但移动一步就扣-0.1。确保智能体做出正确行为时,奖励净值是明显正向的。初期可以给予更“慷慨”的奖励。
- 检查观察值:观察空间是否包含了足够且正确的信息?打印出几组观察值,看看数值是否在合理范围内(有无NaN或极大值)。确保
Player引用不为空。 - 简化任务:是不是一开始太难了?让玩家AI先站着不动,只训练Boss的移动和基础攻击。使用课程学习,从简单任务开始。
- 调整超参数:增加
beta(熵系数)来鼓励探索,比如从5.0e-3调到1.0e-2。降低learning_rate有时也能让学习更稳定。 - 检查动作执行:在
OnActionReceived中打印接收到的动作值,看看是否在预期范围内。连续动作是否被正确应用到Rigidbody或CharacterController上?
4.2 问题:智能体学会“作弊”或出现怪异行为
- 现象:Boss获得了高奖励,但行为不符合预期。例如,它可能疯狂旋转而不攻击(因为旋转有微小奖励),或者卡在墙角(因为那里距离奖励计算有误)。
- 排查与解决:
- 审查奖励漏洞:仔细检查每一步的奖励添加逻辑。是不是在
Update()而不是OnActionReceived()里加了奖励?是不是有 unintended 的奖励源?奖励函数必须绝对精确地反映你的设计意图。 - 增加负奖励:对于不希望出现的行为,明确给予惩罚。例如,对无意义的持续旋转施加一个微小的负奖励。
- 修改观察值:如果Boss卡墙角,可以在观察值中加入“是否碰撞到墙壁”的布尔值,并对此施加负奖励。
- 约束动作空间:给连续动作(如移动速度)加上合理的物理限制,防止出现不现实的移动。
- 审查奖励漏洞:仔细检查每一步的奖励添加逻辑。是不是在
4.3 问题:训练不稳定,奖励曲线剧烈波动
- 现象:奖励曲线像过山车,没有收敛的趋势。
- 排查与解决:
- 减小
learning_rate:过高的学习率可能导致策略更新步伐太大,无法稳定。尝试将其降低一个数量级(如从3e-4降到1e-4)。 - 增加
batch_size和buffer_size:更大的批次和缓冲区能让梯度估计更稳定。 - 检查环境随机性:每次
OnEpisodeBegin()重置时,随机化是否过于剧烈?比如Boss和玩家的出生点距离极远或极近。适当控制随机范围,保证初始状态在一个合理的分布内。 - 确保环境确定性:除了你设计的随机源,物理引擎、Time.deltaTime等是否可能导致不同步?在训练时,尽量固定随机种子。
- 减小
4.4 问题:技能释放逻辑混乱
- 现象:Boss乱放技能,或者在冷却时仍试图释放。
- 排查与解决:
- 在动作执行端做限制:就像我们代码里写的,在
ExecuteSkill中,如果技能在冷却中或动作值是0(无技能),直接return。不要依赖AI自己学会“技能在冷却时不能放”这个规则。 - 提供清晰的冷却信号:观察值中的技能冷却状态必须准确。确保
IsSkillOnCooldown方法返回正确的布尔值。 - 对“空放技能”施加惩罚:在奖励函数中,如果AI选择了某个技能但该技能处于冷却状态,可以给予一个小的负奖励,加速它学习冷却机制。
- 在动作执行端做限制:就像我们代码里写的,在
4.5 模型导出与应用
训练完成后,在results目录下会找到最好的模型文件(.onnx格式)。
- 导入模型:在Unity中,将生成的
.onnx文件拖入Project视图。 - 切换为推理模式:在Boss的
Behavior Parameters组件中,将Behavior Type从Default改为Inference,然后在Model栏中拖入刚才导入的.onnx模型文件。 - 移除或禁用
Decision Requester:推理模式下,模型会自动每帧计算,通常不需要Decision Requester。 - 测试:运行游戏,你的Boss现在就会使用训练好的大脑进行战斗了!
训练一个强大的AI Boss绝非一蹴而就,它需要你像打磨游戏关卡一样,反复迭代观察空间、动作空间和奖励函数。每一次训练结果的反馈,都是你与AI的一次对话。当你看到Boss从最初的跌跌撞撞,到后来能娴熟地走位、连招、甚至诱敌深入时,那种成就感是无可比拟的。最关键的是,这个Boss的“套路”是涌现出来的,而非你设定的,这为你的游戏带来了真正动态和持久的挑战性。