简介:对Python在游戏辅助与AI训练方向感兴趣的开发者,可以借由《赛尔号》场景了解登陆器的网络交互封装、自动化操作思路,以及强化学习在战斗与任务中的试探性实现。压缩包共2000个文件,总体量约42.75MB,其中1258个txt承载脚本说明与运行日志,607个bmp记录界面状态与图像样本,24个py为核心代码,另有dll、exe等运行组件和杂项资源,从组织方式可看出作者完整的实验推进过程。目前已有165人学习下载。透过代码与截图,既能看requests、解析库在游戏通讯中的实际用法,也能了解Q-learning、DQN等算法落地时对状态、动作和奖励函数的权衡设计,还附有中间过程的调试记录与图像素材。适合具备基础Python能力、希望结合具体案例探索流程自动化与强化学习实操的读者。
1. 经典老页游与 Python:登录器、强化学习,一个 zip 里串起来的两条技术线
先说结论:这份《经典老页游与 Python:登陆器,强化学习等》资源包,并不是一个“下载下来双击就能自动打游戏”的黑匣子,而是一套把老页游自动化这件事拆成两半的教学项目。一半是登录器,负责账号会话保持、页面元素操作、状态读取;另一半是强化学习,负责在抽象出的战斗环境里做决策训练。我当时拿到这个包的第一反应是:命名里“登陆器”三个字容易让人误判,真正值钱的是它展示的“外部状态抽取 + 环境封装 + 决策模型”的衔接方式。适合有 Python 基础、写过 requests 或爬虫,但还没碰过强化学习的人;也适合想把游戏辅助脚本升级成学习项目的开发者。老页游本身只是靶场,换到其他回合制场景,思路可以直接复用。
我不建议你把这里面的登录器直接用在线上账号上,合规风险不说,页游接口一改就废。把它当成一个能跑通全链路的样例,才是正确的打开方式。
2. 拆包先看整体:目录结构、依赖与两个核心模块的分工
2.1 看起来有点乱,但边界其实很清楚
资源包解压后,第一眼是“塞得挺满”,实际上核心只有两块。我把典型目录结构简化如下:
classic_game_python/ ├── login/ # 登录器模块 │ ├── client.py # 会话保持、登录接口 │ ├── browser.py # 页面自动化(pyppeteer) │ ├── state_export.py # 把页面状态导出为 JSON │ └── config.ini # 账号、请求头、超时配置 ├── rl/ # 强化学习模块 │ ├── env.py # Gymnasium 风格战斗环境 │ ├── train.py # DQN 训练入口 │ ├── replay.py # 经验回放/优先回放 │ └── evaluate.py # 固定 seed 的评估脚本 ├── data/ │ ├── battle_state.json # 登录器导出的战斗状态 │ └── replay_buffer.pkl # 训练过程落盘 └── requirements.txt为什么我说它边界清楚?因为login目录和rl目录之间没有互相 import。两者通过data/battle_state.json传递信息:登录器只负责“从页面拿状态、把动作指令发回页面”,强化学习只负责“根据状态给动作编号”。这种解耦方式很实用,假设你想换个页游项目,改的只是登录器这半边,算法可以原封不动迁移到别的环境上。
2.2 运行流程与先决条件
完整链路是这样的:
login/client.py先登录,拿到会话令牌和 Cookie。login/browser.py拉起游戏页面,进入战斗场景。login/state_export.py读取页面上的技能编号、双方血量、回合数,写成本地 JSON。rl/env.py读取该 JSON 作为观测,强化学习模型输出动作。- 动作由
login/client.py发回页面,页面产生新状态,再导出,形成闭环。
先决条件不复杂,Python 3.8 以上即可,但依赖版本有讲究。我习惯用虚拟环境安装:
python -m venv .venv source .venv/bin/activate # Windows 下执行 .venv\Scripts\activate pip install -r requirements.txtrequirements.txt里主要锁了这几个库:requests(登录接口)、pyppeteer(页面自动化)、opencv-python(验证码图片处理)、gymnasium(强化学习环境接口)、torch(模型训练)。这里有个隐藏的坑:pyppeteer 对 Python 3.12 的支持不完整,建议用 3.10 或 3.11,否则安装阶段就会报async_generator相关错误。别问我是怎么知道的,问就是第一遍装的时候没看版本。
跑通整个链路之前,先把两半边分别验证:登录器能顺利导出状态,然后手动改battle_state.json,确认rl/env.py读取正常。两个模块单独能转,再合起来做端到端。
2.3 登录器模块的关键参数
login/config.ini是第一个要改的文件,它决定了登录器能不能“活”过风控。常见配置项如下:
[account] username = your_account password = your_password [connection] timeout = 10 retry = 3 request_interval = 1.2 [browser] headless = true window_size = 1280,720 start_wait = 2timeout是每个 HTTP 请求的等待上限,我一般给 10 秒,老页游接口响应不稳定,太短容易误判登录失败。retry = 3表示失败后重试三次,重试之间要用request_interval做间隔,避免短时间连续请求被限流。start_wait很关键,它控制浏览器加载页面后等待多少秒再去找元素。这个值既不能太大,也别太小,2 秒起步,实际跑的时候如果元素还没渲染,可以往上调到 3 到 5 秒。
登录器的目标不是取代人工操作,而是提供一个稳定可复现的“状态读写通道”。后续强化学习训练的所有数据,都依赖这个通道输出的一致性。
3. 登录器实现:会话保持、页面操作与状态导出
3.1 用 requests.Session 撑住登录态
老页游的登录接口通常会做两步:先 GET 拿一个随机令牌,再 POST 账号密码和令牌。如果一上来就 POST,大概率返回“参数错误”。我拆的这个包,第一步就是这么实现的:
import requests from configparser import ConfigParser from pathlib import Path class LoginClient: """登录客户端:负责维持会话,提供登录与动作下发接口。""" def __init__(self, config_path: str = "login/config.ini"): cfg = ConfigParser() cfg.read(config_path, encoding="utf-8") account = cfg["account"] conn = cfg["connection"] # Session 会自动保存服务端下发的 Cookie,不需要手动维护 cookie 字典 self.session = requests.Session() self.session.headers.update({ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" ), "Referer": "https://entry.example.local/", # 按实际入口填 }) self.username = account["username"] self.password = account["password"] self.timeout = int(conn.get("timeout", fallback=10)) self.retry = int(conn.get("retry", fallback=3)) def login(self) -> dict: # 第一步:先 GET 拿令牌,这个令牌可能藏在 JSON 里,也可能在页面里 token_resp = self.session.get( "https://api.example.local/login/token", timeout=self.timeout ) token_resp.raise_for_status() token = token_resp.json().get("token") if not token: raise RuntimeError("未在响应中解析到 token") # 第二步:携带账号密码与令牌发起正式登录 login_resp = self.session.post( "https://api.example.local/login", data={ "username": self.username, "password": self.password, "token": token, }, timeout=self.timeout, ) login_resp.raise_for_status() return login_resp.json()这里有一个很实用的点:requests.Session会自动把服务端Set-Cookie存下来,后续请求不需要手动把 cookie 塞进 headers。很多新手在这里喜欢自己拼Cookie字符串,结果登录态一过期就出问题。用 Session,令牌刷新后也会随着新响应自动更新,省掉一大半麻烦。
Referer我强烈建议保留,老页游的风控逻辑会校验来源页,缺失或错误的 Referer 会直接导致“登录失败”。如果你发现同样的账号密码,浏览器里能登,脚本里登不上,优先排查这个头。
3.2 页面自动化:等元素,而不是盲等
登录之后,战斗页面是动态渲染的,直接 requests 拿不到实时状态。所以登录器里用了 pyppeteer 做页面自动化。这里最容易翻车的地方是等待策略,新手喜欢time.sleep(5),但页面加载快慢受网络影响很大,固定睡眠要么等不够,要么浪费时间。更稳的做法是等待元素出现:
import asyncio from pyppeteer import launch async def auto_login_and_enter_battle(headless: bool = True): """拉起浏览器、登录、进入战斗页,并等待战斗按钮可用。""" browser = await launch( headless=headless, args=["--no-sandbox", "--disable-blink-features=AutomationControlled"], ) page = await browser.newPage() await page.goto("https://entry.example.local/", {"waitUntil": "networkidle0"}) await page.waitForSelector("#login-form", timeout=15000) # 验证码处理:这里用人工输入,OCR 方案后面再展开 captcha_path = await page.screenshot(element="#captcha-img") captcha_text = input("检查 captcha.png,输入验证码:") await page.type("#captcha-input", captcha_text) await page.click("#login-btn") # 登录成功后等待战斗入口,而不是直接 sleep try: await page.waitForSelector("#battle-entry", timeout=10000) except Exception: # 页面停留太慢时,降低要求,等按钮变成可点击状态 await page.waitForSelector("#battle-entry:not([disabled])", timeout=10000) await page.click("#battle-entry") # 进入战斗后,等第一个回合状态容器出现 await page.waitForSelector("#round-info", timeout=15000) return browser, page参数里waitUntil: "networkidle0"表示等网络中 0 个连接时才算加载完,适合单页应用;如果页面有轮询请求,可能会一直等不到,这时要退而求其次,改用"domcontentloaded"。#battle-entry:not([disabled])这种写法是 CSS 选择器过滤不可点击状态,发动页面点击逻辑时,比先判断 class 再点击要省事得多。
验证码是这个流程里最“玄学”的部分。资源包的做法是截验证码图片、人工输入,把自动化接口留了出来。我建议先别上 OCR,等整条链路跑通后再考虑识别,否则一个问题叠一个问题,最终很难定位是登录器坏了还是模型坏了。
3.3 状态导出:把页面变成可被环境消费的 JSON
强化学习要的不是 DOM,而是结构化的状态。state_export.py的核心逻辑是从页面里提取数值型字段,拼成 JSON 写入data/battle_state.json:
import json from pathlib import Path async def export_battle_state(page) -> None: """从页面提取战斗状态,统一写盘。""" state = await page.evaluate(""" () => { const text = s => document.querySelector(s)?.innerText.trim(); return { hp_me: parseInt(text('#me-hp') || 0, 10), hp_opp: parseInt(text('#opp-hp') || 0, 10), round: parseInt(text('#round-info') || 1, 10), skill_cd_0: parseInt(text('#skill-0-cd') || 0, 10), skill_cd_1: parseInt(text('#skill-1-cd') || 0, 10), skill_cd_2: parseInt(text('#skill-2-cd') || 0, 10), skill_cd_3: parseInt(text('#skill-3-cd') || 0, 10), }; } """) Path("data").mkdir(exist_ok=True) with open("data/battle_state.json", "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2)这段 JS 在浏览器页面里直接执行,把 ID 对应的文本转成数字。需要注意:parseInt解析失败时会得到NaN,而NaN序列化到 JSON 会变成null,后续环境读取时要靠or 0这类兜底逻辑。我在实际跑的时候,遇到过技能 CD 读出来是"∞"这种文本,parseInt("∞")直接给NaN,这里就得特殊处理,比如把非数字文本映射成 -1。这个细节很影响奖励计算,因为技能冷却没读到,模型会以为技能可用,训练出来的策略全是“无效动作”。
状态字段的选择不是越多越好。页面上能读到的属性可能有几十个,但我建议只选模型真正要用的:双方血量、当前回合、四个技能 CD。字段太多,模型要学的东西就多,收敛反而变慢;字段太少,状态不充分,奖励再塑形也没用。先按资源包给的 12 维跑,再逐步加字段对比效果。
4. 强化学习环境:把老页游战斗封装成 Gymnasium 接口
4.1 状态、动作、奖励怎么定才合理
封装强化学习环境时,最忌讳的是“把页面原样搬进环境”。页游页面里有大量与决策无关的信息,比如皮肤特效、聊天栏滚动、按钮动画,这些全部要丢掉。合理的抽象是:
- 状态:我方血量、对方血量、回合数、四个技能冷却。总共 12 维。
- 动作:四个技能编号,离散动作空间。
- 奖励:对敌方造成伤害为正,受到伤害为负,战斗胜利给额外奖励,失败给惩罚,回合数超过上限给终止。
奖励塑形是这包里最值得研究的部分。如果完全等战斗结束才给奖励,几百回合打完才知道“赢”还是“输”,样本利用率很低。我当时拆这个包时,注意到它的奖励设计成“每回合增量式”:这一回合打了 30 点伤害,那这一步的奖励就是30 / 初始血量。这样做的好处是让模型很快学到“放技能打人”这个基本方向,而不是在前几百轮里完全摸不着头脑。
4.2 环境骨架:五个返回值和状态生命周期
Gymnasium 新版本要求step返回五个值,老版 gym 的四个返回值习惯要改过来。下面是我按资源包思路重写的环境骨架:
import json from pathlib import Path import gymnasium as gym import numpy as np class BattleEnv(gym.Env): """老页游战斗环境:读取 state JSON,决定动作编号。""" metadata = {"render_modes": ["human"]} def __init__(self, state_path: str = "data/battle_state.json"): super().__init__() self.state_path = Path(state_path) # 动作空间:4 个技能 self.action_space = gym.spaces.Discrete(4) # 观测空间:12 维连续数,范围 0 到 9999 self.observation_space = gym.spaces.Box( low=0, high=9999, shape=(12,), dtype=np.float32 ) self.current_step = 0 self.max_steps = 100 def reset(self, seed=None, options=None): """ 重置环境到初始回合,并返回 (obs, info)。 新版 Gymnasium 必须返回两个值,少一个都会在训练时报错。 """ super().reset(seed=seed) self.current_step = 0 # 实际项目中,这里应调用登录器重新进入一场战斗 obs = self._read_obs() info = {"round": 1} return obs, info def step(self, action: int): """ 执行一个动作,返回 (obs, reward, terminated, truncated, info)。 terminated 表示一局结束;truncated 表示达到步数上限。 """ self.current_step += 1 # 实际项目中,这一步会把 action 变成页面上技能按钮的点击 # 这里用模拟方式演示:伤害与技能编号相关 import random damage = {0: 10, 1: 18, 2: 25, 3: 40}[action] hp_me = 100.0 hp_opp = 100.0 hp_opp -= damage reward = damage / 100.0 if hp_opp <= 0: reward += 1.0 # 如果超过最大步长,强制结束 terminated = hp_opp <= 0 or hp_me <= 0 truncated = self.current_step >= self.max_steps obs = self._read_obs() info = { "round": self.current_step, "hp_me": hp_me, "hp_opp": max(hp_opp, 0), } return obs, reward, terminated, truncated, info def _read_obs(self) -> np.ndarray: """从本地状态文件读取观测。文件不存在时返回全零向量。""" if not self.state_path.exists(): return np.zeros(12, dtype=np.float32) with open(self.state_path, encoding="utf-8") as f: state = json.load(f) keys = [ "hp_me", "hp_opp", "round", "skill_cd_0", "skill_cd_1", "skill_cd_2", "skill_cd_3", ] obs = np.array([state.get(k, 0) for k in keys], dtype=np.float32) # 保护:观测里有 NaN 时,替换为 0,避免训练直接崩溃 return np.nan_to_num(obs, nan=0.0)最关键的一点是reset和step的返回值格式。新版 Gymnasium 把“回合结束”拆成了terminated和truncated两个布尔值,很多教程还在用done,混用会直接解包失败。我这边第一次训练时报错就是“not enough values to unpack”,原因就是我照着老教程写了obs, reward, done, info = env.step(action),而环境返回了五个值。
max_steps这个参数也值得说。老页游单场战斗回合上限通常很高,如果环境不做截断,训练时遇到“谁也打不死谁”的局面,一局要跑几百步,样本全被无效回合占满。我习惯把上限压到 100,超过就给一个很小的负奖励,让模型学会尽早结束战斗。
4.3 先跑随机策略验证闭环,再谈训练
启动强化学习训练之前,我强烈建议先跑一个“随机策略冒烟测试”,连模型都不用建,只验证环境本身是否自洽:
import numpy as np from rl.env import BattleEnv def smoke_test(episodes: int = 20): env = BattleEnv() total_reward = 0.0 finished = 0 for ep in range(episodes): obs, info = env.reset(seed=100 + ep) done = False while not done: action = env.action_space.sample() # 随机动作 obs, reward, terminated, truncated, info = env.step(action) total_reward += reward if terminated or truncated: done = True if terminated: finished += 1 print(f"平均收益: {total_reward / episodes:.4f}") print(f"正常结束占比: {finished / episodes:.2%}") if __name__ == "__main__": smoke_test()随机策略的意义在于暴露环境底层问题:如果reward声称有值但平均收益始终是 0,说明奖励链路断了;如果正常结束占比一直是 0,说明truncated没生效,步数上限有 bug;如果不同 seed 下结果波动极大,说明_read_obs里读到的状态不稳定。这些 bug 在训练前修掉,成本最低。等 DQN 跑起来后再发现环境问题,你根本分不清是模型问题还是环境问题,调试难度翻倍。
冒烟测试通过后,再引入 DQN。资源包里的训练入口是标准做法:每步存经验到回放池,每 100 步从池里采样更新一次目标网络。要注意的一点是,页游动作反馈天然有延迟,一个技能点击后可能要 1 到 2 秒才产生伤害数值,所以训练时建议把frame_skip概念用上:同一个动作连续执行 2 次再观测新状态,能减少动作切换带来的抖动。
5. 常见问题排查:环境装不上、登录失败、训练不收敛的六个坑
5.1 坑一:pyppeteer 首次运行卡在下载浏览器
现象:执行launch()时,控制台停在“Downloading chromium”进度条,半小时不动,最后超时。
原因:pyppeteer 首次运行需要下载内置 Chromium,下载源在国外 CDN,网络稍差就卡住。
解决:先去本机确认是否已有 Chrome 或 Chromium,有的话直接指定executablePath,跳过下载:
browser = await launch( executablePath="/opt/google/chrome/chrome", headless=True, )如果实在没有可用浏览器,就手动下载 Chromium 压缩包,放到本地目录后,用环境变量PYPPETEER_CHROMIUM_REVISION指定版本,再把executablePath指到解压后的二进制文件。从那以后,我对所有用到 pyppeteer 的项目,第一件事就是确认浏览器路径,而不是让它自动下载。
5.2 坑二:登录接口返回“参数错误”,但浏览器里能正常登录
现象:脚本里 POST 账号密码,返回错误码;同一个账号在浏览器手动登录没问题。
原因:缺少两步中的第一步,也就是没有先 GET 拿到令牌;或者Referer头不对。
解决:回到源码里,检查登录动作是否完整实现“先取 token 再带 token POST”。如果前端接口有签名逻辑,还要额外处理签名参数。我踩过的更隐蔽问题是请求头里的Origin字段,某些接口会校验它必须和页面域名一致,少了这个字段会默认拒绝。加一行self.session.headers["Origin"] = "https://entry.example.local"就通过了。
5.3 坑三:点击页面技能按钮没反应,但控制台不报错
现象:动作编号已经发给登录器,页面上没看到技能出手,训练时 reward 全是 0。
原因:元素选择器失效,或者按钮处于禁用状态。老页游的按钮在技能冷却期间会加上 disabled class,点击事件监听器不会触发。
解决:在点击之前,先用waitForSelector等待按钮可用,并且每轮点击前重新获取元素引用,不要复用之前的 handle。另外,把动作下发改成走页面内部接口,会比模拟点击更稳:
state = await page.evaluate(""" async (skillId) => { const resp = await fetch('/battle/skill', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({skill: skillId}) }); return await resp.json(); } """, action_id)这样绕过了按钮状态检查,直接把技能请求发出去,前提是你能在页面控制台里拿到这个接口的请求方式。拿不到的话,还是老实监听按钮的click事件。
5.4 坑四:验证码 OCR 识别率很高,但登录成功率反而变低
现象:单独测试 OCR 模型,识别单个数字准确率 95% 以上;跑完整登录流程,成功率却不到一半。
原因:验证码图片在脚本截取时带了缩放或偏移,截出来的图跟模型训练集分布不一致。
解决:截验证码之前,先等图片元素完全加载,并固定浏览器视口大小。更实用的做法是,把截下来的验证码图片和原图拼接在一起,人工看一眼再决定要不要调 OCR 参数。我在这个坑上花了两天,最后发现问题是截图区域留了白边,模型训练时用的图片没白边。从那以后,我给自己定了条规矩:所有图像预处理步骤,必须在和线上完全相同的截图上调试。
5.5 坑五:DQN 训练 loss 一直下降,但胜率不升反降
现象:训练曲线显示 loss 从 2 降到 0.5,看起来在收敛;实际打战斗,胜率不到 10%。
原因:回放池里采样比例失衡,前期大量“失败回合”样本占满缓存,模型学到的是“怎么快速输掉”,而不是“怎么赢”。
解决:给胜利样本和造成高伤害的样本提高采样权重,用优先回放(Prioritized Replay)。实现不复杂:每条经验除了(state, action, reward, next_state, done),再存一个优先级数值,采样时按优先级概率取。具体代码在下一章给。
5.6 坑六:训练日志显示每回合步数很长,但实际游戏早就结束了
现象:truncated一直为 False,回合步数冲到 200 以上,而页面上双方血量已经为负。
原因:环境读的battle_state.json是旧文件,step里没有在动作后重新导出最新状态,或者导出时机不对。
解决:确认动作下发后,页面状态更新完成再写 JSON。我在实战里使用的办法是在导出函数里轮询读取血量字段,连续两次读到的值一致才判定状态稳定:
async def wait_state_stable(page, selector, stable_rounds=2): last_value = None stable_count = 0 while stable_count < stable_rounds: value = await page.evaluate( f"() => document.querySelector('{selector}')?.innerText.trim()" ) if value == last_value: stable_count += 1 else: stable_count = 0 last_value = value await asyncio.sleep(0.5) return value这个轮询看着笨,但能有效过滤页面渲染中的中间态。血量从 100 变成 80,中间可能会闪现 95、92 等过渡值,如果不等稳定就写盘,环境观测会被污染,训练曲线直接乱掉。
6. 回归验证与进阶:优先回放、自博弈和固定 seed 评估
到了这一步,登录器和环境已经能闭环,接下来是怎么让训练结果可信。我每次调完环境,不会直接看训练曲线,而是先跑一遍固定 seed 评估脚本,把随机策略和训练策略放在同一批初始状态下打 N 场,对比平均奖励和回合数。下面的函数是资源包里评估逻辑的简化版:
import numpy as np def evaluate(env, policy, episodes=100, seed_offset=0): """在固定 seed 序列下评估策略,返回平均奖励与平均回合数。""" total_reward = 0.0 total_rounds = 0 wins = 0 for ep in range(episodes): obs, info = env.reset(seed=1000 + seed_offset + ep) done = False while not done: action = int(policy(obs)) obs, reward, terminated, truncated, info = env.step(action) total_reward += reward if terminated or truncated: done = True if terminated and reward > 0: wins += 1 total_rounds += info["round"] return { "avg_reward": total_reward / episodes, "avg_rounds": total_rounds / episodes, "win_rate": wins / episodes, }policy是一个接收 obs、返回动作编号的可调用对象。你可以先传随机策略得到基线,再传训练策略,两份结果放一起才说明改进。如果随机策略平均奖励是 -0.2,训练策略是 0.3,这才算有效。如果训练策略连基线都没跑赢,问题大概率在奖励设计,而不是模型结构。
进阶部分,我给这套资源补了两个常用技巧。第一个是优先回放,回放池里每条经验算一个 TD 误差作为优先级,采样时优先抽误差大的样本。简化实现如下:
import random import numpy as np class PrioritizedBuffer: def __init__(self, capacity=20000, alpha=0.6, beta=0.4): self.capacity = capacity self.alpha = alpha self.beta = beta self.buffer = [] self.priorities = [] def push(self, transition, priority=1.0): if len(self.buffer) < self.capacity: self.buffer.append(transition) self.priorities.append(priority) else: # 容量满时,用新样本替换优先级最低的旧样本 idx = int(np.argmin(self.priorities)) self.buffer[idx] = transition self.priorities[idx] = priority def sample(self, batch_size): probs = np.array(self.priorities, dtype=np.float32) ** self.alpha probs /= probs.sum() indices = np.random.choice(len(self.buffer), batch_size, p=probs) batch = [self.buffer[i] for i in indices] return batch, indices第二个技巧是自博弈。固定打一个 NPC 对手,模型会很快过拟合,比如只针对某个技能的释放时机做优化。更稳的训练方式是维护一个对手池,每隔一段时间把当前模型的影子参数放进池里,让模型和多个历史版本互相打。这个思路在资源包里没有完整实现,但环境接口已经支持两个策略对打,因为状态观测里同时包括双方血量和 CD,你只需要把“玩家”和“对手”当成两个独立策略分别下发动作即可。
从那以后,我每次调这个环境,第一步永远是跑固定 seed 的随机策略评估,确认环境没被改坏,再谈训练参数。这个习惯救了我很多次,希望帮到你。
本文还有配套的精品资源,点击获取