
简介对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, encodingutf-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, fallback10)) self.retry int(conn.get(retry, fallback3)) def login(self) - dict: # 第一步先 GET 拿令牌这个令牌可能藏在 JSON 里也可能在页面里 token_resp self.session.get( https://api.example.local/login/token, timeoutself.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, }, timeoutself.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( headlessheadless, args[--no-sandbox, --disable-blink-featuresAutomationControlled], ) page await browser.newPage() await page.goto(https://entry.example.local/, {waitUntil: networkidle0}) await page.waitForSelector(#login-form, timeout15000) # 验证码处理这里用人工输入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, timeout10000) except Exception: # 页面停留太慢时降低要求等按钮变成可点击状态 await page.waitForSelector(#battle-entry:not([disabled]), timeout10000) await page.click(#battle-entry) # 进入战斗后等第一个回合状态容器出现 await page.waitForSelector(#round-info, timeout15000) 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.jsonimport 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_okTrue) with open(data/battle_state.json, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2)这段 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( low0, high9999, shape(12,), dtypenp.float32 ) self.current_step 0 self.max_steps 100 def reset(self, seedNone, optionsNone): 重置环境到初始回合并返回 (obs, info)。 新版 Gymnasium 必须返回两个值少一个都会在训练时报错。 super().reset(seedseed) 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, dtypenp.float32) with open(self.state_path, encodingutf-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], dtypenp.float32) # 保护观测里有 NaN 时替换为 0避免训练直接崩溃 return np.nan_to_num(obs, nan0.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(seed100 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, headlessTrue, )如果实在没有可用浏览器就手动下载 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_rounds2): 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, episodes100, seed_offset0): 在固定 seed 序列下评估策略返回平均奖励与平均回合数。 total_reward 0.0 total_rounds 0 wins 0 for ep in range(episodes): obs, info env.reset(seed1000 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, capacity20000, alpha0.6, beta0.4): self.capacity capacity self.alpha alpha self.beta beta self.buffer [] self.priorities [] def push(self, transition, priority1.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, dtypenp.float32) ** self.alpha probs / probs.sum() indices np.random.choice(len(self.buffer), batch_size, pprobs) batch [self.buffer[i] for i in indices] return batch, indices第二个技巧是自博弈。固定打一个 NPC 对手模型会很快过拟合比如只针对某个技能的释放时机做优化。更稳的训练方式是维护一个对手池每隔一段时间把当前模型的影子参数放进池里让模型和多个历史版本互相打。这个思路在资源包里没有完整实现但环境接口已经支持两个策略对打因为状态观测里同时包括双方血量和 CD你只需要把“玩家”和“对手”当成两个独立策略分别下发动作即可。从那以后我每次调这个环境第一步永远是跑固定 seed 的随机策略评估确认环境没被改坏再谈训练参数。这个习惯救了我很多次希望帮到你。本文还有配套的精品资源点击获取