
简介基于强化学习的坦克大战Python项目面向AI入门与游戏开发学习者演示BP神经网络与强化学习在游戏对战中的应用。项目内置人工操控与AI自动对战两套系统玩家可通过WASD与空格手动控制坦克AI坦克则借助训练好的模型自动索敌与攻击。资源共84个文件压缩包约55.7MB主体为18个Python脚本含TensorFlow/Keras训练代码、pygame游戏逻辑与41张PNG纹理素材另有录屏GIF、XML配置等便于查看效果与二次开发。运行run_examples.py即可切换战斗模式run_RF.py可重新训练模型完整覆盖从环境交互到决策输出的实现链路。目前已有1561人学习浏览适合希望结合游戏案例理解强化学习、BP网络及pygame实践的学习者。1. 坦克大战的AI化为什么这局胜负要看强化学习在CartPole上稳定收敛的DQN代码搬到坦克大战里通常前几百回合都是废的——不是算法坏了是环境的观察维度、奖励边界和决策时序没对齐。坦克大战地图不大、单位也不多但博弈复杂度不低敌方坦克会机动炮弹有飞行时间墙体遮挡视野智能体不仅要决定“当前是否开火”还得预判“对方下一步会不会躲进掩体”。这本质上是一个典型序贯决策问题用Q学习框架搭配参数化值函数是当前最直接可行的解法。这套实现的重点放在环境状态编码、BP神经网络Q值逼近和DQN训练流程三个环节代码用纯Python加PyTorch完成适合刚跑完入门级强化学习任务、想换一个更有博弈感的场景的开发者也适合需要坦克对战环境做算法对比的工程师直接改造。2. 环境建模观察空间设计与奖励塑形的真实边界2.1 状态编码从游戏画面到特征向量坦克大战游戏里每个单位都有坐标、朝向、血量和装填状态但神经网络不能直接吃这些原始值。原因有二一是绝对坐标的量纲在不同地图里不一致换张地图数值分布变了之前训练的模型基本失效二是把整个画面按像素展开成向量维度会膨胀到几千甚至上万BP神经网络这种全连接结构需要大量样本来拟合训练效率很差。常规做法是把每个决策瞬间整理成一个固定长度的特征向量所有数值统一归一化到相近量级。下面是我在坦克大战环境里常用的12维状态构造覆盖了“自身状态、最近目标、最近威胁”三类信息。def build_state_vector(game_state): # 玩家自身位置归一化到 [0,1]朝向用 0-3 编码后归一化 state [ game_state.player.x / MAP_W, game_state.player.y / MAP_H, game_state.player.dir / 3.0, ] # 最近的敌方坦克相对坐标和归一化距离 enemy nearest_enemy(game_state) state.extend([ (enemy.x - game_state.player.x) / MAP_W, (enemy.y - game_state.player.y) / MAP_H, distance(player, enemy) / (MAP_W MAP_H), ]) # 最近的敌方子弹存在与否用标记位不存在的维度补 0 bullet nearest_enemy_bullet(game_state) if bullet is not None: state.extend([ (bullet.x - game_state.player.x) / MAP_W, (bullet.y - game_state.player.y) / MAP_H, 1.0, ]) else: state.extend([0.0, 0.0, 0.0]) # 自身生命值和弹药冷却标记 state.extend([ game_state.player.hp / 3.0, 0.0 if game_state.player.can_fire else 1.0, ]) return np.array(state, dtypenp.float32)几个关键点说明一下。相对位置比绝对位置稳定它天然具备平移不变性——敌人从左侧逼近和从右侧逼近在特征上只差一个符号模型不必为每个坐标值单独学习一套规则。子弹信息建议单独给一组维度并且用标记位区分“有子弹”和“没子弹”否则空值补零时模型会把“没有威胁”和“威胁在原点”混淆。生命值放在最后作为判断是否冒险的参考信号归一化到 01。2.2 动作空间设计离散动作与决策频率坦克大战的“可行动作”表面上是移动和射击但实际建模时需要拆开。我采用一个 6 维离散动作空间对经典坦克大战的操作方式做了一点简化移动动作直接绑定朝向省掉单独转向这一步。动作索引含义说明0向上移动朝向改为上前进一格1向下移动朝向改为下前进一格2向左移动朝向改为左前进一格3向右移动朝向改为右前进一格4静止停留且不开火用于观察等待5射击朝当前朝向发射一枚炮弹为什么不把射击和移动组合成更多动作因为组合动作会让输出维度从 6 涨到 20 以上而游戏里大多数状态下边移动边开火并不是最优解。把射击单独拆出来模型更容易学到“先调整位置再停下来瞄准”的策略。决策频率同样关键。我一般每 5 帧做一次决策单帧时长设定为 100ms也就是每 500ms 选择一个动作。如果每帧都决策相邻状态差异极小经验样本的相关性会变得非常高DQN 训练时梯度更新方向容易来回震荡。反过来如果决策间隔太长坦克机动会显得很迟钝子弹飞行的快速博弈就没法体现。2.3 奖励塑形稀疏奖励与过程奖励的取舍坦克大战的奖励设计是最容易被低估的部分。如果只给“击杀敌方坦克 10自己阵亡 -10”这种稀疏信号模型初期完全是在随机碰撞很难形成有效的梯度信号。我采用稀疏奖励和过程奖励叠加的方式基础规则如下击杀一辆敌方坦克立即获得 10自己被击中扣血给予 -3 惩罚开火一次给予 -0.1 的微小消耗惩罚每个决策步给予 -0.01 的生存时间惩罚鼓励尽快结束战斗奖励方案击杀奖励开火惩罚单步惩罚收敛表现纯稀疏10无0前 1000 回合几乎不收敛回报曲线一直在负值徘徊稀疏 单步10无-0.01开始有策略迹象但不射击坐以待毙稀疏 过程10-0.1-0.01收敛最快模型会主动索敌开火开火惩罚是很多人容易忽略的细节。不加这个惩罚模型会学到“不停开火”因为反正子弹打不中有代价打中了有巨大收益。加上 -0.1 的期望损耗后模型开始倾向于在炮口对准敌人时才开火整体命中率反而更高。奖励幅度也要控制。如果一次性给到 100BP 神经网络的输出层在初期会被推向很大的数值配合 MSE 损失会产生巨大梯度训练很容易发散。我一般把正向收益限制在 10 以内负向惩罚限制在 -10 以上。3. BP神经网络作Q值逼近器从Q表格到函数拟合的替换逻辑3.1 为什么是BP神经网络而不是Q表格经典 Q 学习用一张表记录每个状态动作对的期望回报这种思路在状态空间可枚举的场景下是可行的。但坦克大战的观察向量是 12 维浮点数每个维度取值都在连续区间里理论上状态空间是无穷大的表格根本没有办法枚举。更麻烦的是泛化问题两个相近但不同的状态如果分别记录模型无法知道它们应该共享部分行为策略。BP 神经网络作为参数化函数逼近器输入是状态向量输出是每个动作的 Q 值。相邻状态在网络参数空间中是连续映射的会被平滑地编码到相近的表示中这就在样本数量有限的前提下获得了泛化能力。延迟方面对坦克大战这个量级的游戏三层全连接网络单次前向传播在毫秒量级配合训练循环里每 500ms 一个决策的节奏完全跑得动。3.2 网络结构与参数初始化在坦克大战这个任务里我用的 BP 网络结构如下import torch.nn as nn import torch class BPNet(nn.Module): def __init__(self, state_dim12, action_dim6): super(BPNet, self).__init__() self.fc1 nn.Linear(state_dim, 64) # 输入层到第一隐层 self.fc2 nn.Linear(64, 64) # 第一隐层到第二隐层 self.fc3 nn.Linear(64, action_dim) # 第二隐层到输出层 def forward(self, x): x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return self.fc3(x) # 输出层不加激活Q 值允许为正或负两层 64 维隐层是这个任务里性价比最高的配置。一层 64 维可以拟合线性加轻微非线性的决策面但对坦克大战这种需要判断威胁和机会的博弈场景不够三层以上参数量上涨明显训练需要更多样本而坦克大战单局经验获取速度有限。ReLU 激活函数解决了深层网络中梯度消失的问题相比 sigmoid 或 tanh它在正半轴的梯度恒为 1信息可以顺畅地反向传播。网络层输入维度输出维度参数数量全连接层 1126412 × 64 64 832全连接层 2646464 × 64 64 4160输出层64664 × 6 6 390输出层不加激活函数的原因是 Q 值的取值范围本身是 0.4 至 10 甚至更大的正负区间用 tanh 会把输出限制在 -1 到 1 之间Q 值表达空间被压缩用 sigmoid 更是只能输出正值。直接把线性层接出来让网络自己学合适的输出范围是最稳妥的。模型总参数量大约 5400 个这个体量决定了训练时对显存和内存的需求很低在纯 CPU 环境也能跑完整流程。3.3 前向传播与反向传播的损失计算Q 学习的目标是让当前 Q 值逼近贝尔曼目标值。前向传播得到的是 6 个动作的 Q 值但训练时只更新实际执行的那个动作对应的 Q 值其余动作的输出不直接参与损失矫正。def compute_loss(batch, online_net, target_net, gamma0.99): states, actions, rewards, next_states, dones batch # 前向传播得到所有动作的 Q 值再按实际执行的动作索引取出对应 Q q_all online_net(torch.FloatTensor(states)) # (batch, 6) q_chosen q_all.gather(1, actions.unsqueeze(1)).squeeze(1) # (batch,) # 目标 Q 值 即时奖励 gamma × 下一状态的最大 Q且终止状态不计 with torch.no_grad(): q_next target_net(torch.FloatTensor(next_states)).max(1).values y_target rewards gamma * q_next * (1 - dones) # 均方误差作为损失反向传播只作用于选中的那个动作对应路径 loss nn.MSELoss()(q_chosen, y_target) return loss这里有几个细节值得展开。第二行那个gather操作是关键它把 [-batch 个状态的前向 Q 值] 中对应执行动作的列抽出来网络虽然输出了 6 个值但只有 1 个值对损失有贡献反向传播的梯度也只流向该动作对应的输出路径。目标值计算为什么要做max(1)因为 Q 学习假设下一状态会执行最优动作取最大 Q 值代表“未来可能得到的最高回报”。(1 - dones)这个掩码确保终止状态后不再累计未来奖励——如果坦克已经阵亡就没有下一状态了。缩放原因如果这一步还有动作奖励应乘gamma如果没有就是0。损失计算完成后调用loss.backward()和optimizer.step()更新权重。优化器我选用 Adam初始学习率 1e-3它通过为每个参数保留单独的动量和适应学习率来稳定更新配合小规模网络通常不需要再做学习率衰减。4. DQN训练循环经验回放、目标网络与探索调参4.1 经验回放缓冲区的实现与容量如果按时间顺序把相邻帧的经验样本直接喂给网络样本之间存在极强的时间相关性而 Q 学习更新假设样本来自独立同分布分布。这在线性函数逼近下是严重的稳定性问题。经验回放通过存储历史样本、随机采样打破相关性。from collections import deque import numpy as np import random class ReplayBuffer: def __init__(self, capacity20000): self.buffer deque(maxlencapacity) # deque 超出容量时自动丢弃最旧样本 def push(self, state, action, reward, next_state, done): # 统一转成 float32避免后续批量转换时出现 dtype 不一致 self.buffer.append(( np.array(state, dtypenp.float32), int(action), float(reward), np.array(next_state, dtypenp.float32), 1.0 if done else 0.0, )) def sample(self, batch_size): # 随机抽取 batch_size 条经验打乱时间顺序消除样本间相关性 transitions random.sample(self.buffer, batch_size) states, actions, rewards, next_states, dones zip(*transitions) return (np.stack(states), np.array(actions), np.array(rewards), np.stack(next_states), np.array(dones))容量设多少合适我试过 5000、20000 和 100000结论是坦克大战这类中短局游戏20000 已经能覆盖大约 200 局游戏的经验量。容量太小的直接后果是频繁丢弃早期样本模型只会记住最近几十局的行为灾难性遗忘变得非常明显。容量过大则会让采样偏向冷门的历史数据训练收敛速度变慢而且占内存很大每一条 12 维样本加 12 维下一状态100000 条大约要占到 60MB 以上。sample里random.sample是不放回抽样同一个 batch 里不会出现同一条经验。这一点保证了梯度更新的稳定性。如果改成choices有放回抽样同一个 batch 里可能多次出现同一条样本实际 batch 样本多样性会被打折扣。4.2 目标网络防止追着移动目标更新的训练崩溃如果用同一个网络计算当前 Q 值和目标 Q 值每次梯度更新都会同时抬高目标和预测两条曲线相互追逐训练过程会发散成一个震荡系统。解决办法是维护一份独立的、更新节奏更慢的目标网络参数副本。def soft_update(target_net, online_net, tau0.005): # tau 控制目标网络向在线网络逼近的速度 for tp, op in zip(target_net.parameters(), online_net.parameters()): tp.data.copy_(tau * op.data (1 - tau) * tp.data)使用标准 DQN 时更常见的硬拷贝方案是每 N 局直接做一次target_net.load_state_dict(online_net.state_dict())这种做法的缺点在于目标值在拷贝瞬间发生一次跳变损失曲线的毛刺增多。软更新则让目标网络每一步都以很小的系数追赶在线网络目标值平滑变化损失曲线更平稳。坦克大战这个场景里我倾向 tau 0.005 的软更新训练大约 300 个回合后目标网络逐渐脱离随机初始化参数的影响。4.3 训练主循环摆脱随机到策略收敛的全流程下面把完整训练循环串起来包括探索、存储、采样、更新目标网络四个环节。def train(env, online_net, optimizer, episodes2000, batch_size64): replay_buffer ReplayBuffer(capacity20000) target_net BPNet() target_net.load_state_dict(online_net.state_dict()) # epsilon 从 1.0 开始前期几乎全随机逐步降低 epsilon 1.0 epsilon_min 0.05 epsilon_decay 0.998 for episode in range(episodes): state env.reset() episode_reward 0.0 while True: # epsilon-greedy 策略随机数小于 epsilon 时探索否则贪婪利用 if random.random() epsilon: action random.randrange(6) else: with torch.no_grad(): q_vals online_net(torch.FloatTensor(state).unsqueeze(0)) action int(torch.argmax(q_vals).item()) next_state, reward, done env.step(action) replay_buffer.push(state, action, reward, next_state, done) state next_state episode_reward reward # 缓冲区攒到足够样本才开始训练 if len(replay_buffer.buffer) batch_size: batch replay_buffer.sample(batch_size) loss compute_loss(batch, online_net, target_net) optimizer.zero_grad() loss.backward() # 梯度裁剪防线防梯度爆炸 torch.nn.utils.clip_grad_norm_(online_net.parameters(), 2.0) optimizer.step() soft_update(target_net, online_net, tau0.005) if done: break epsilon max(epsilon_min, epsilon * epsilon_decay) if episode % 50 0: print(fepisode {episode:4d} | reward {episode_reward:7.2f} | eps {epsilon:.3f})这里的流程拆开来理解epsilon-greedy工作在探索和利用之间做权衡。replay_buffer.push在每步都执行但训练更新只在缓冲区样本量达到 batch_size 之后才开始这就避免了训练初期缓冲区不满时梯度方向过于偏激。梯度裁剪在这里很重要Q 值目标偶尔会因为奖励异常产生大梯度clip_grad_norm把梯度向量的范数限制在 2.0 以内能有效防止训练一步之后权重出现突变。epsilon 衰减策略采用指数衰减初始 1.0每次迭代乘 0.998。照这个衰减速度大约 300 回合后降到 0.4 左右之后模型进入利用阶段。4.4 超参数速查表与常见失败模式参数推荐取值调高时影响调低时影响学习率1e-3收敛快但训练振荡加剧收敛慢容易陷入局部策略gamma 折扣因子0.99模型考虑远期收益但延迟反馈同步慢短视只看眼前几步缓冲区容量20000样本多样性提升内存占用上升样本相关性高容易遗忘旧经验batch_size64梯度更稳定单次更新耗时增加更新频繁梯度方差大epsilon_min0.05保留更多探索行为激进选择减少过早收敛到局部动作选择tau0.005目标网络越新训练越接近无目标网络目标越陈旧学习速度放慢训练中常见问题集中在三个方向。第一种是 loss 发散——表现为 loss 值直接跳到 NaN 或指数级增长几乎都是奖励值幅度过大或学习率太高造成的把奖励乘 0.1 或把学习率降到 1e-4 通常能救回来。第二种是训练前期 loss 锯齿状——在 20 到 50 的区间内反复震荡这多半是缓冲区容量太小或 batch_size 过小。第三种是回合奖励始终没有起色——观察坦克的动作序列如果始终在一个方向来回移动说明动作空间设计有问题检查是否有某个动作没被探索到比如“射击”需要先转向才能生效。5. 训练前期的三个稳定性技巧从发散到收敛的排查顺序坦克大战的 DQN 训练初期是最容易翻车的时间段回报曲线大起大落是常态。下面三个技巧按排查优先级排列能在前 200 回合把环境问题尽量排除干净。技巧一先跑 100 回合随机策略建立奖励基线。在训练开始之前把当前环境里里外外搅拌一遍采样分布不要直接用初始随机权重开始学。具体做法是固定 epsilon1.0让坦克完全随机选择动作跑完 100 回合记录每回合的平均奖励和平均回合时长。如果这个基线数值是 -15那说明随机策略平均不到 5 步就会被击中奖励设计损失太大。如果基线值是 -3 附近说明负向惩罚适中网络有平稳输出空间。这里的关键在于把“环境问题”和“算法问题”分开排查基线都过差就别指望网络能学出来。技巧二梯度裁剪加损失裁剪直接把发散概率降一个量级。loss.backward() torch.nn.utils.clip_grad_norm_(online_net.parameters(), max_norm2.0) optimizer.step()把max_norm设为 2.0一般场景已经够用。如果奖励设计里加入了较大的负惩罚值比如 -20把 max_norm 降到 1.0 更合适。梯度裁剪不是万能的它只能限制梯度爆炸不能解决奖励设计本身不合理的问题但能保证训练不会在中途因为一个异常样本直接崩掉。技巧三用零采样评估代替 sumireward 曲线判断收敛。训练过程中打印的单局 reward 带有随机性看不出真实策略水平。我每 100 回合额外做一个评估把 epsilon 固定为 0连跑 20 局记录平均回合时长和平均命中率。正常的收敛序列是“高回合时长 → 回合时长下降 → 命中率上升”——模型先学会躲避再学会击杀。如果评估命中率一直低于 10%检查射击动作是否被正确触发如果回合时长在上升但命中率也在下降说明模型学会了“龟缩”策略这时奖励设计里的开火惩罚或单步生存惩罚需要重新微调。三套排查对应关系异常现象优先检查项常见原因loss 发散 / NaN奖励幅度正向奖励超过 20 或学习率超过 1e-3单局 reward 震荡加剧缓冲区容量容量小于 5000样本被重复抽样覆盖策略龟缩不动开火惩罚 / 单步惩罚负向惩罚过大模型选择“不动不开火”命中率始终上不去射击动作定义射击绑定方向转向时机影响开火窗口训练过程中的评估代码把env.reset(random_seed)固定种子保证可比性。def evaluate(model, env, episodes20): total_hits 0 total_steps 0 for _ in range(episodes): state env.reset() done False while not done: with torch.no_grad(): q_vals model(torch.FloatTensor(state).unsqueeze(0)) action int(torch.argmax(q_vals).item()) state, _, done env.step(action) total_steps 1 total_hits env.statistic[hits] return total_hits / episodes, total_steps / episodes评估函数里的env.statistic[hits]是环境内部维护的命中计数器每一局结束后取出来除以局数就得到平均命中率。固定随机种子后这套评估曲线的可信度比较高可以作为后续调整奖励权重和网络结构的对照基准。本文还有配套的精品资源点击获取