强化学习实战-用强化学习打跑酷游戏 GreatWallRun 第三节 强化学习层构建 临时笔记
强化学习层技术笔记 — GreatWallRun
基于 Stable Baselines3 PPO 的手游自动化 RL 训练系统
核心代码:ppo_acting.py/game_env.py/train_ppo.py
一、整体架构:三层分离 + 多线程通信
1.1 设计哲学
系统严格遵循“感知层只感知,决策层只决策”的职责分离原则。三层之间通过共享内存变量和同步原语通信,不允许跨层直接调用。
┌─────────────────────────────────────────────────────────────────┐ │ train_ppo.py │ │ (训练编排层) │ │ PPO("MlpPolicy", env, tensorboard_log=...) │ │ model.learn(total_timesteps=100000) │ │ 断点续训 / 最优模型保存 / Ctrl+C 中断保护 │ └──────────────────────────┬──────────────────────────────────────┘ │ env.step(action) │ env.reset() ▼ ┌─────────────────────────────────────────────────────────────────┐ │ game_env.py │ │ (环境决策层 — Master) │ │ │ │ gymnasium.Env 接口: │ │ • observation_space: Box(207,) — 200 terrain + 7 state │ │ • action_space: Discrete(3) — 0:待机 1:跳 2:射 │ │ │ │ 内部逻辑: │ │ • _calculate_reward(action) — 8 项奖励/惩罚 │ │ • _get_obs() — 构建 207 维观测向量 │ │ • _update_terrain() — 200 维地形采样 │ │ • _update_run_stats(action) — 当局统计 │ │ │ │ 与 Worker 通信: │ │ • queue.put(action) → 发送动作指令 │ │ • action_done.wait() ← 等待执行完成 │ │ • 读取共享状态变量 ← 感知数据 │ │ • death_triggered / env_restart_order ⇄ 死亡重开握手 │ └──────────────────────────┬──────────────────────────────────────┘ │ queue.Queue(maxsize=1) │ threading.Event (action_done) │ 共享变量 (共享内存) ▼ ┌─────────────────────────────────────────────────────────────────┐ │ ppo_acting.py │ │ (感知执行层 — Worker) │ │ │ │ Thread 1 — 帧抓取线程 (全速 30 FPS): │ │ cap.read() → _latest_frame (共享缓冲,Lock 保护) │ │ │ │ Thread 2 — 主循环 (YOLO + 渲染 + 动作执行): │ │ _latest_frame → 悬崖检测 → YOLO → RuleEngine → 更新共享状态 │ │ → 处理动作队列 → 渲染 3 窗口 → cv2.waitKey(1) │ │ │ │ Thread 3 — OCR 线程 (每 0.3s): │ │ _latest_frame → Tesseract → shared_lives/arrows/coins/distance │ └─────────────────────────────────────────────────────────────────┘1.2 为什么不用 OpenAI Gym 标准的单进程模式?
标准 Gym 中env.step()内部直接执行动作并返回下一状态。但在本项目中:
- 动作延迟不可忽略— ADB 点击到游戏画面变化有 300-500ms 延迟,必须等待
- 感知必须持续运行— YOLO 推理不能因为
step()没被调用就停止,否则画面滞后 - 多消费者— OCR、YOLO、game_env 三者都需要最新的游戏画面
因此采用了独立 daemon 线程 + 队列同步的方案:
ppo_acting的感知循环永远运行,不依赖step()调用game_env.step()只是一个"下单 → 等结果"的同步点- 这种模式类似于异步环境或client-server RL architecture
1.3 同步原语设计
| 原语 | 类型 | 方向 | 语义 |
|---|---|---|---|
env_action_queue | queue.Queue(maxsize=1) | Master → Worker | 动作指令,容量为 1 强制同步 |
action_done | threading.Event | Worker → Master | 动作已执行 + 冷却完成 + 感知已更新 |
perception_ready | threading.Event | Worker → Master | 首帧感知完成,环境可以开始 |
death_triggered | bool | Worker → Master | 检测到死亡(lives=0 或距离停滞) |
env_restart_order | bool | Master → Worker | 确认死亡,执行重开点击流程 |
_stats_lock | threading.Lock | 双向 | 保护训练统计 dict 的读写 |
_frame_lock | threading.Lock | 单向 | 保护_latest_frame的并发访问 |
二、观测空间设计
2.1 总览
observation_space = Box(0, 1, shape=(207,), dtype=float32) ┌─────────────────────┬──────────┬──────────────────────────┐ │ 部分 │ 维度 │ 来源 │ ├─────────────────────┼──────────┼──────────────────────────┤ │ 地形向量 │ 200 │ 像素级地面颜色扫描 │ │ 最近障碍物距离 │ 1 │ RuleEngine.danger_list │ │ 最近敌人距离 │ 1 │ RuleEngine.enemy_list │ │ 最近 Soul 距离 │ 1 │ RuleEngine.soul_list │ │ 最近金币距离 │ 1 │ RuleEngine.coin_list │ │ 箭矢数 │ 1 │ OCR shared_arrows │ │ 金币数 (累计) │ 1 │ OCR shared_coins │ │ 生命值 │ 1 │ OCR shared_lives │ │ 总计 │ 207 │ │ └─────────────────────┴──────────┴──────────────────────────┘2.2 地形向量 (200 维)
采样方法:
从主角马匹的右边界horse_pos[2]开始,向右每隔 3 像素采样一次,固定 200 个点。每个采样点读取画面底部往上 15 像素处的颜色,与GROUND_COLORS对比判定是否为地面。
forxinrange(start_x,min(start_x+200*3,w),3):pixel=frame[h-15,x]is_ground=(pixel ≈ GROUND_COLORS[0]orpixel ≈ GROUND_COLORS[1])terrain_slice.append(1.0ifis_groundelse0.0)归一化:直接为[0, 1]值,无需额外缩放。
为什么采样 200 个点?
- 1920×1080 分辨率下 200×3 = 600px 足够覆盖马匹前方到屏幕右边缘的全部地面
- 200 维足够让 MLP 策略网络感知到"前方空洞 = 悬崖"的 pattern
- 3px 间隔在精度和向量长度间折中
2.3 状态向量 (7 维)
距离归一化:
danger_dist/500# 超过 500px 视为无穷远enemy_dist/500soul_dist/500coin_dist/500选择 500 作为分母是因为游戏画面中超过 500px 的物体已接近屏幕边缘,Agent 不需要区分 500px 和 800px —— 都"很远"。
HUD 归一化:
arrows/100# 箭矢上限约 99coins/500# 金币可能累积数百lives/10# 生命值通常 1-5生命值不做上限截断——如果lives > 10也能正确归一化到> 1.0,策略网络会从数值本身学到"生命很多"。
三、动作空间设计
3.1 动作定义
| Action | 名称 | ADB 操作 | 游戏效果 |
|---|---|---|---|
| 0 | 待机 | 无 | 角色自然奔跑 |
| 1 | 跳跃 | adb_tap(300, 300) | 跳过障碍/悬崖/拾取头顶金币 |
| 2 | 射击 | adb_swipe(horse → enemy, offset=300) | 消灭前方敌人 |
3.2 为什么只有 3 个动作?
- 大招(加速)未加入— 需要消耗金币,决策复杂度过高,先不纳入 RL
- 没有方向控制— 游戏是自动奔跑的卷轴跑酷,角色自动前进
- 没有"向下滑"— 游戏不提供下蹲/滑铲操作
- 射击方向自动瞄准— swipe 的终点根据
shoot_target(最近敌人)自动计算
3.3 射击坐标计算
# 从马的位置向敌人方向延伸 SHOOT_OFFSET=300pxhx,hy=horse 中心坐标 ex,ey=最近敌人的中心坐标 dx,dy=ex-hx,ey-hy ratio=SHOOT_OFFSET/dist end_x=hx+dx*ratio end_y=hy+dy*ratio# 远距离射击时加 Y 轴补偿(箭矢抛物线)ifdist>=DISTANCE_SWITCH(240px):end_y+=SHOOT_Y_OFFSET(-50px)# 向上抬,模拟抛物线3.4 动作冷却
不同动作的执行时间不同,冷却时间也不同:
| Action | 冷却 | 原因 |
|---|---|---|
| 0 待机 | 0.0s | 无物理操作 |
| 1 跳跃 | 0.5s | tap 瞬间完成,但需要等角色起跳→落地 |
| 2 射击 | 0.6s | swipe 100ms + 箭矢飞行 + 游戏动画 |
冷却以非阻塞方式实现:执行动作后记录cooldown_until = now + interval,下一帧信号检查时若冷却已过 + 新帧感知完成 → 才action_done.set()。
四、奖励函数设计
4.1 设计原则
- 密集信号优先— 距离增量每步都给,Agent 每步都有反馈
- 关键事件高权重— 悬崖跨越 +20、死亡 -50,明确强化/抑制
- 防止 OCR 噪声— 所有 OCR 来源的 delta 都做上限裁剪
- 引导合理行为— 战斗惩罚引导 Agent 不浪费箭矢、不放任敌人
4.2 完整奖励项
详见REWARD.md,此处只列核心逻辑:
def_calculate_reward(action):reward=0.0# P1 生存税 — 打破全正奖励reward-=0.005# R1 距离增量 — 核心正向驱动delta_dist=current_dist-prev_distif|delta_dist|<=50:# OCR 防抖reward+=delta_dist*0.1# R2 金币增量delta_coins=current_coins-prev_coinsif0<delta_coins<=10:# OCR 防抖reward+=delta_coins*1.0# R3 障碍越过 — 跟踪 nearest danger 的 x 坐标变化ifnearest_danger_x 换了:reward+=2.0# R4 悬崖跨越 — True → Falseifcliff 解除:reward+=20.0# P2 无目标射击 — action=2 但无敌或 >300pxifaction==2andnotenemy_close:reward-=0.3# P3 有敌不射 — 敌人 <300px 但 action≠2ifaction!=2andenemy_close:reward-=0.8# P4 死亡ifdone:reward-=50.04.3 典型一局预算
跑 2000m, 捡 20 币, 过 15 障碍, 跨 2 悬崖, 射 10 次 生存税: -0.005 × 400步 = -2.0 距离: +0.1 × 2000 = +200.0 金币: +1.0 × 20 = +20.0 障碍: +2.0 × 15 = +30.0 悬崖: +20.0 × 2 = +40.0 射击奖惩: ≈ 0.0 ──────────────────────────────── 合计: ~+288.0 死亡: -50.0 (17%)死亡惩罚约占一局总正奖励的 17%,既能起到威慑作用,又不会让 Agent 过度保守(不敢冒险跳跃)。
4.4 奖励 shaping 的反面:为什么不加"按键惩罚"?
有些 RL 项目对每次行动(action≠0)施加小惩罚,防止 Agent 疯狂按键。本项目不需要:
- 冷却机制已经限制频率— Agent 每秒最多 2 次动作
- 无效射击已有专门惩罚— 比通用按键惩罚更有针对性
- 生存税已经打破全正奖励— 无需额外 per-action 惩罚
五、死亡检测与重开闭环
5.1 死亡判定(双重机制)
主判定:OCR 读到 lives=0 → 立即标记 death_triggered=True └─ 优点:零延迟,不会有"死后还在选动作"的问题 兜底判定:距离停滞 2s → 标记 death_triggered=True └─ 用途:OCR 偶尔漏读 lives=0 的情况5.2 端到端时序
时间线: T+0.0s lives=0 → death_triggered=True → step() 返回 done T+0.0s PPO 调用 reset() T+1.0s 等待 OCR 稳定(0.3s×3 周期) T+1.0s 采样 dist_before T+3.0s 采样 dist_after T+3.0s dist_before == dist_after → 确认死亡 T+3.0s env_restart_order = True → Worker 收到指令 T+3.0s _death_check_enabled = False ← 锁住死亡检测 T+3.0s tap(300, 300) — 进入死亡页面 T+6.0s tap(960, 1000) — 点击重开 T+8.0s 等待游戏加载 T+8.0s _death_check_enabled = True ← 恢复死亡检测 T+8.0s reset() 返回初始 obs — 新局开始5.3 关键保护机制
| 保护 | 机制 | 防止的问题 |
|---|---|---|
| OCR 滞后误判 | 等 1s 再采样,确保 OCR 稳定 | lives 归零但距离读数还在更新 → 误判为"还活着" |
| 2s 距离验证 | 两次采样对比 | OCR 短暂异常读数 → 误判死亡 |
_death_check_enabled锁 | 重开期间完全关闭死亡检测 | 死画面上 lives=0 持续触发 → 无限循环重启 |
六、多线程交互时序
6.1 正常 Step 的完整交互
train_ppo game_env ppo_acting 主循环 OCR 线程 │ │ │ │ │ env.step(1) │ │ │ │ ──────────────► │ │ │ │ │ queue.put(1) │ │ │ │ ────────────────────► │ │ │ │ │ │ │ │ action_done.wait() │ │ │ │ (阻塞, 最多 2s) │ │ │ │ │ │ │ │ ┌─────┴─────┐ │ │ │ │ pop 动作 1 │ │ │ │ │ adb_tap │ │ │ │ │ 设 cooldown│ │ │ │ │ pending= │ │ │ │ │ True │ │ │ │ └─────┬─────┘ │ │ │ │ │ │ │ ┌──────────┴──────────┐ │ │ │ │ 继续跑感知循环 │ │ │ │ │ 读帧 → 悬崖 → YOLO │ │ │ │ │ → 更新共享状态 │ │ │ │ └──────────┬──────────┘ │ │ │ │ │ │ │ ┌─────┴─────┐ │ │ │ │ cooldown │ │ │ │ │ 已过? │ │ │ │ │ YES → │ │ │ │ │ action_ │ │ │ │ │ done.set()│ │ │ │ └─────┬─────┘ │ │ │ │ │ │ │ ◄─ action_done 收到 ──┘ │ │ │ │ │ │ │ 读共享状态: │ │ │ │ horse_pos, │ │ │ │ danger_list, │ │ │ │ shared_distance ... │ │ │ │ │ │ │ │ _calculate_reward() │ │ │ │ _get_obs() │ │ │ │ │ │ │ ◄─ (obs, r, done)│ │ │ │ │ │ │6.2 关键时序保证
为什么action_done一定在感知更新后?
主循环的执行顺序保证了:
while True: 1. 读新帧 ← 上轮动作的效果已经体现在画面中 2. 感知 (YOLO + rule_engine) ← 更新所有共享状态 3. 如果 pending_signal + cooldown_done: action_done.set() ← game_env 醒来读到的一定是动作后的状态 4. 如果 queue 非空: 执行动作 ← 标记 pending_signal=True 5. 渲染当 game_env 被action_done.set()唤醒时,第 2 步已经完成,共享状态是最新的。
为什么选择maxsize=1的 Queue?
maxsize=1意味着 game_env 的queue.put()会阻塞直到 Worker 消费了上一个动作- 这自然形成了背压机制:Agent 不可能领先 Worker 超过 1 个动作
- 比无限 queue + 手动同步更简洁健壮
七、PPO 训练配置
7.1 超参数与设计意图
| 参数 | 值 | 设计意图 |
|---|---|---|
policy | MlpPolicy | 207 维观测 → 3 维动作,MLP 足够。不需要 CNN |
n_steps | 256 | 每 256 步做一次策略更新。约等于 2-3 局游戏 |
batch_size | 64 | 256 步分 4 个 batch 更新,平衡训练速度和稳定性 |
n_epochs | 5 | 每轮数据重放 5 次。RL 环境数据不能过拟合 |
learning_rate | 3e-4 | PPO 标准值,太大了策略震荡,太小了收敛慢 |
gamma | 0.99 | 重视长期回报。游戏可以跑很久,远期的生存也很重要 |
gae_lambda | 0.95 | 标准值,平衡优势估计的偏差和方差 |
clip_range | 0.2 | PPO 核心:限制每次更新的幅度,防止策略崩溃 |
ent_coef | 0.05 | 略高,鼓励探索。游戏有随机性(敌人/障碍随机出现) |
vf_coef | 0.5 | 标准值,价值函数损失权重 |
7.2 模型保存策略
| 类型 | 触发 | 用途 |
|---|---|---|
ppo_greatwall_N_steps.zip | 每 10000 步 | 定期 checkpoint,断点续训 |
ppo_greatwall_best.zip | episode reward 创新高 | 训练中最佳模型 |
ppo_greatwall_final.zip | 训练完成 | 最终模型 |
ppo_greatwall_interrupt.zip | Ctrl+C | 中断保护 |
BestModelCallback是自定义的——监听Monitor包装器在每个 episode 结束时写入infos的episode字段,比较ep_reward。
八、设计决策 FAQ
Q: 为什么 RuleEngine 仍然在 ppo_acting 中运行?RL 不是不应该用规则吗?
RuleEngine 的输出(danger_list,enemy_list等)是作为观测的一部分输入给 RL 的,不是用来做决策的。它本质上是一个特征提取器——把 YOLO 的边界框列表转化为"最近的障碍物距离 230px"这种结构化信息。PPO 策略自己决定跳不跳。
Q: 为什么 terrain 向量用像素采样而不是 YOLO 检测?
悬崖是地面缺失,不是可检测的"物体"。YOLO 不知道地面长什么样。像素扫描是唯一可靠的悬崖检测方式。
Q: 为什么 OCR 读 HUD 而不是让 YOLO 检测数字?
OCR 识别任意数字比训练 YOLO 检测每个数字(0-9)更灵活通用。Tesseract 专门优化了数字识别。
Q: reset() 中的 2s 等待会不会浪费时间?
只在死亡时发生。一局游戏可能几百上千步,一次 3 秒的 reset 开销可忽略。更重要的是它确保了重开可靠性。
占位图
我们挑几张验证效果看看