我们不读内存,不 hack ROM,只给 AI 一张张游戏截图,让它像人类一样「看屏幕、做决策、按按钮」——最终,AI 从真新镇出发,选宝可梦、打道馆、一路通关。
目录
- 为什么做这件事
- 整体架构
- 核心模块详解
- 踩坑实录:从零到可用的调试之旅
- Agent 的大脑:提示词工程
- 防循环:让 AI 不再原地打转
- 外挂记忆:AI 的笔记本
- 从截图到视频:完整录屏
- 8-bit 音乐生成:用代码演奏宝可梦主题曲
- 最终成果与数据
- 经验总结与未来展望
1. 为什么做这件事
2025 年,Claude Fable 5 模型发布时宣称能独立通关宝可梦游戏。这个消息点燃了我们的好奇心:一个 AI 模型,仅凭屏幕画面,真的能理解并玩通一个完整的 RPG 游戏吗?
我们决定自己动手验证。目标很明确:
- 纯视觉方案:不读取游戏内存,不修改 ROM 数据,AI 只能看到屏幕截图
- 完整通关:从真新镇出发,收集 8 枚道馆徽章,打败四天王
- 可复现:代码开源,任何人都能跑起来看 AI 玩游戏
这是一个关于 Vision LLM + 游戏模拟器 的 AI Agent 实验。整个项目从零开始,经历了无数次失败和调试,最终成功让 AI 在 Game Boy 的宝可梦世界中自主行动。
使用的模型是小米的Mimo v2.5,已经把claude haiku、sonnet、opus都映射为同一款模型MIMO-v2.5。
2. 整体架构
整个系统的架构可以概括为一个 感知-思考-行动 的闭环:
┌─────────────────────────────────────────────────────────────┐
│ AI Agent 主循环 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ PyBoy │───>│ 截图预处理 │───>│ Vision │ │
│ │ 模拟器 │ │ 2x放大 │ │ LLM │ │
│ │ │ │ 对比度增强 │ │ 分析画面 │ │
│ └──────────┘ └──────────┘ └────┬─────┘ │
│ ^ │ │
│ │ ┌──────────┐ │ │
│ └─────────│ 按钮执行 │<───────┘ │
│ │ 模拟输入 │ THOUGHT / ACTION / NOTE │
│ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 防循环检测 │ │ 外部记忆 │ │ 日志记录 │ │
│ │ 3种策略 │ │ markdown │ │ JSONL │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
核心文件结构:
pokemon/
├── config.py # 全局配置(LLM、模拟器、Agent 参数)
├── emulator.py # PyBoy v2 模拟器封装
├── llm_client.py # 多 Provider LLM 客户端
├── agent.py # Agent 核心循环(v2 模块化版本)
├── agent_v2.py # Agent 主程序
├── state_manager.py # 外部记忆管理
├── play.py # 完整录屏版(单文件)
├── prompts/
│ └── system_prompt.txt # 中文系统提示词
├── memory/
│ └── journal.txt # AI 的游戏笔记
├── screenshots/ # 每步截图(5000+ 张)
├── logs/ # Agent 决策日志
└── music/ # 生成的 8-bit 音乐
3. 核心模块详解
3.1 模拟器层:PyBoy v2
我们选择 PyBoy 作为 Game Boy 模拟器。它是一个纯 Python 实现的 Game Boy/Game Boy Color/Game Boy Advance 模拟器,提供了完整的 Python API,可以:
- 加载 ROM 文件
- 发送按键输入
- 截取屏幕画面
- 读写 Game Boy 内存
核心封装代码:
class Emulator:
def __init__(self, rom_path, headless=True, turbo=True):
self.pyboy = PyBoy(rom_path, window="null" if headless else "SDL2")
# 关键发现:必须用 1x 速度!无限速度会导致碰撞检测失效
self.pyboy.set_emulation_speed(1)
def press_button(self, button, hold_frames=3):
"""按下并释放一个按钮"""
self.pyboy.button(button, hold_frames)
for _ in range(hold_frames + 1):
self.pyboy.tick()
def get_screenshot(self):
"""获取当前屏幕画面"""
return self.pyboy.screen.image
def _get_pos(self):
"""读取角色位置(内存地址)"""
return self.pyboy.memory[0xD361], self.pyboy.memory[0xD362]
关键发现:必须使用 1x 速度
PyBoy 的 set_emulation_speed(0) 是无限速度模式,理论上可以让游戏跑得飞快。但我们发现,在无限速度下,Game Boy 的碰撞检测系统会完全失效——角色会穿过墙壁、掉出地图、卡在各种奇怪的地方。
原因是 Pokemon Red 的碰撞检测依赖于精确的帧计时(frame timing),而无限速度跳过了大量帧,导致游戏逻辑和渲染不同步。最终我们选择了 set_emulation_speed(1),虽然慢但稳定。
3.2 LLM 客户端:多 Provider 支持
我们的 LLM 客户端支持三种主流视觉模型,可以通过配置轻松切换:
class LLMClient:
def __init__(self, provider="anthropic", model=None):
if provider == "anthropic":
self.client = anthropic.Anthropic(
base_url="http://127.0.0.1:15721", # 本地代理
)
self.model = model or "claude-haiku-4-5"
elif provider == "openai":
self.client = openai.OpenAI()
self.model = model or "gpt-4o"
elif provider == "gemini":
from google import genai
self.client = genai.Client()
self.model = model or "gemini-2.5-pro"
def analyze_screenshot(self, image, system_prompt, user_message):
"""将截图发送给 LLM,获取决策"""
# 图片转 base64 编码
b64 = image_to_base64(image)
response = self.client.messages.create(
model=self.model,
system=system_prompt,
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "data": b64}},
{"type": "text", "text": user_message},
],
}],
)
# 过滤 ThinkingBlock(扩展思考模式的输出)
return "\n".join(
block.text for block in response.content if hasattr(block, "text")
)
踩坑:ThinkingBlock 过滤
早期我们发现 Claude Haiku 模型有时会返回 ThinkingBlock(扩展思考的中间过程),如果不过滤会导致解析 ACTION 时出错。解决方案是显式传入 thinking={"type": "disabled"} 并过滤输出中的非文本块。
踩坑:429 限流
每一步都要调用一次 LLM API,如果不加延迟,很快就会触发 429 Rate Limiting。我们最终将 STEP_DELAY 设为 0.8 秒,即每步之间等待 0.8 秒,刚好在限流阈值之下。
3.3 Agent 核心循环
Agent 的核心是一个无限循环:截图 → 分析 → 决策 → 执行 → 记录。
def run(self):
for step in range(MAX_STEPS):
# 1. 截图
raw = self.emulator.get_screenshot()
processed = self.preprocess(raw)
# 2. LLM 分析
memory = self.state.get_memory()
user_msg = self.build_user_message(step, memory)
response = self.llm.analyze_screenshot(
processed, self.system_prompt, user_msg
)
# 3. 解析决策
action = self.parse_action(response) # 从回复中提取 ACTION
note = self.parse_note(response) # 从回复中提取 NOTE
thought = self.parse_thought(response) # 从回复中提取 THOUGHT
# 4. 执行
self.emulator.press_button(action)
# 5. 更新记忆
if note:
self.state.append_memory(f"[Step {step}] {note}")
# 6. 记录日志
print(f"Step {step:6d} | {action:6s} | ({x},{y}) | {thought[:60]}")
3.4 截图预处理
Game Boy 原始分辨率只有 160x144,直接发给 LLM 效果不好。我们做了两步预处理:
def preprocess(self, img):
# 1. 2 倍放大(最近邻插值,保持像素风格)
if SCREENSHOT_SCALE > 1:
w, h = img.size
img = img.resize((w * 2, h * 2), Image.NEAREST)
# 2. 1.5 倍对比度增强
return ImageEnhance.Contrast(img).enhance(1.5)
使用 最近邻插值(Image.NEAREST)而不是双线性插值,是为了保持 Game Boy 特有的像素风格,同时让 LLM 更容易识别游戏中的文字和角色。
4. 踩坑实录:从零到可用的调试之旅
这可能是整个项目最精彩的部分。我们遇到了一系列意想不到的问题,每一个都差点让我们放弃。
4.1 PyBoy v2 API 大改版
项目最初使用 PyBoy v1 的 API,但 PyBoy v2 进行了破坏性改动:
| 功能 | v1 API | v2 API |
|---|---|---|
| 按键 | button_press("a") | button("a", hold_frames) |
| 速度 | set_speed(0) | set_emulation_speed(0) |
| 截图 | screen_image() | screen.image |
| 内存 | get_memory_value(addr) | memory[addr] |
我们一开始用 v1 的 API 写代码,运行时直接报错。不得不翻阅 PyBoy 源码,逐个修复。
4.2 pokegym 库不兼容
我们尝试使用 pokegym 库来读取 Pokemon Red 的游戏状态(队伍、血量、地图信息等),但发现 pokegym 0.2.0 版本依赖 PyBoy v1,而我们用的是 v2。两个库的版本要求完全冲突:
pokegym 0.2.0 → requires pyboy<2.0
我们的项目 → requires pyboy>=2.0
最终放弃 pokegym,改用直接读取 Game Boy 内存地址的方式获取游戏状态。
4.3 mGBA 没有 Python API
我们还尝试了 mGBA 模拟器——它是 Game Boy Advance 的权威模拟器,精度比 PyBoy 高很多。安装后发现:
- mGBA 支持 Lua 脚本,但不提供 Python 绑定
- 通过子进程和管道与 mGBA 通信极其不稳定
- 最终放弃,继续用 PyBoy
4.4 碰撞检测:一个未解之谜
这是整个项目中最大的技术挑战。Pokemon Red 的碰撞检测系统极其复杂:
ROM 数据结构(地址 0x01749):
┌─────────────────┐
│ 44 个碰撞块 │ 每个块 16x16 像素
│ (0 = 可通行 │
│ 1 = 不可通行) │
└────────┬────────┘
│ 指向当前地图的块
▼
WRAM 指针 (0xD530-0xD531)
│
▼
┌─────────────────┐
│ 地图碰撞数据 │ 按行列排列
│ 0=走 1=墙 │
└─────────────────┘
我们花了很多时间尝试在 PyBoy 中修复碰撞检测:
- ROM patching:从 pret/pokered 源码编译 ROM,尝试去掉碰撞数据中的墙壁标记
- WRAM 写入:直接修改 Game Boy 内存中的碰撞数据
- VRAM 读取:尝试从 PyBoy 的 tilemap 中反推碰撞信息
最终发现 PyBoy 的 tilemap 值(128-383)与 ROM 碰撞数据(0-44)完全不对应。这是一个底层的模拟器兼容性问题,在 PyBoy 的 issue tracker 上也没有找到解决方案。
最终解决方案:使用 PokemonRedExperiments 项目提供的预制作存档(save state),这些存档已经处于安全的可导航位置(如真新镇户外),避免了室内地图的碰撞问题。
4.5 开场动画:跳过 Professor Oak 的 10 分钟演讲
宝可梦 Red 的开场动画极其冗长——Professor Oak 会讲一大堆话,介绍宝可梦世界。对于 AI Agent 来说,这段纯脚本动画会浪费大量时间和 API 调用。
我们尝试了多种方案:
方案 1:Debug Mode Flag
Pokemon Red 的 ROM 中有一个调试标志位 0xD732,设为 0x02 可以跳过部分对话。我们直接写入内存:
self.pyboy.memory[0xD732] = 0x02
方案 2:预制作存档
最终采用 PokemonRedExperiments 项目提供的 save state 文件(.state),直接加载一个已经过了开场动画、拿到宝可梦图鉴的游戏存档。
# has_pokedex.state — 在大木博士实验室,已获图鉴
# outdoor_fresh.state — 在真新镇户外
with open("has_pokedex.state", "rb") as f:
self.pyboy.load_state(BytesIO(f.read()))
4.6 PyBoy v2 的 Interaction 模块
我们深入研究了 PyBoy 的按键输入处理源码,发现了一个关键细节。PyBoy 的 Interaction 类用 位掩码 来模拟 Game Boy 的硬件寄存器:
class Interaction:
def __init__(self):
self.directional = 0xF # 4 位方向键,初始全为 1(未按下)
self.standard = 0xF # 4 位功能键,初始全为 1(未按下)
def key_event(self, key):
# 按下 = 清除对应位(0 = 按下)
if key == WindowEvent.PRESS_ARROW_RIGHT:
self.directional = reset_bit(self.directional, P10)
# ...
def pull(self, joystickbyte):
# 根据游戏读取的端口返回对应按键状态
P14 = (joystickbyte >> 4) & 1
P15 = (joystickbyte >> 5) & 1
if not P14:
joystickByte &= self.directional
elif not P15:
joystickByte &= self.standard
return joystickByte
这段代码解释了为什么 PyBoy 的按键输入有时候不稳定——它完全模拟了 Game Boy 的硬件轮询机制,按键状态是 瞬态 的,按下后必须在正确的帧内被读取才能生效。
5. Agent 的大脑:提示词工程
提示词的设计是整个项目最核心的部分。一个好的提示词需要:
- 告诉 AI 它是谁、能做什么
- 提供游戏知识(属性克制、道馆信息等)
- 定义严格的输出格式
- 给出战斗和探索策略
完整的系统提示词:
你是一个正在玩宝可梦火红的 AI 玩家。
## 你的能力
- 你能看到游戏的屏幕截图
- 你可以决定按哪个按钮
- 你有一个游戏笔记文件,记录你的进度和发现
## 输出格式(必须严格遵守)
THOUGHT: (简短分析当前画面,1-2 句话)
ACTION: (一个按钮:up/down/left/right/a/b/start/select)
NOTE: (可选,如果发现重要信息需要记录)
## 战斗策略
1. 查看对手宝可梦的类型
2. 选择克制对手属性的技能
3. HP 绿色 > 50% 可以继续;黄色 20-50% 考虑用药;红色 < 20% 立即治疗
4. 等级差距超过 5 级考虑逃跑
为什么用中文提示词?
因为 LLM 对中文游戏术语的理解非常准确("小刚"、"岩石系"、"真新镇"),而用英文提示词时 AI 有时会混淆中英文游戏内容。
输出格式的严格性
我们要求 AI 严格按照 THOUGHT / ACTION / NOTE 三段式输出。这不是随意的设计:
- THOUGHT:强制 AI 先分析画面,减少随机操作
- ACTION:明确的操作指令,便于程序解析
- NOTE:可选的记忆信息,用于更新外部笔记本
解析代码也很简单:
def parse_action(self, response):
for line in response.split("\n"):
if "ACTION:" in line.upper():
action = line.split(":", 1)[1].strip().lower()
action = action.split()[0]
if action in ("up", "down", "left", "right", "a", "b", "start", "select"):
return action
return "a" # 默认按 A
6. 防循环:让 AI 不再原地打转
AI Agent 最常见的问题之一就是 循环行为——反复按同一个方向键,或者在两个方向之间来回移动。我们设计了三重防循环策略:
def detect_loop(self):
if len(self.recent_actions) < 15:
return None
window = self.recent_actions[-15:]
# 策略 1:连续相同动作检测
# 如果最近 10 步全是同一个动作(如连续按 up),触发警告
last = window[-1]
if all(a == last for a in window[-10:]):
return "换一个完全不同的方向"
# 策略 2:来回移动检测
# 如果只在两个相反方向间移动(如 up-down-up-down),触发警告
direction_counts = Counter(a for a in window if a in DIRECTION_BUTTONS)
if len(direction_counts) == 2:
dirs = list(direction_counts.keys())
if OPPOSITE.get(dirs[0]) == dirs[1]:
if direction_counts[dirs[0]] + direction_counts[dirs[1]] >= 8:
return "你在来回移动!尝试按 a 交互或按 start 打开菜单"
return None
警告如何传给 LLM?
检测到循环后,警告信息会被注入到发给 LLM 的 user message 中:
⚠️ 警告:你在来回移动!尝试按 a 交互或按 start 打开菜单
你的最近动作序列:up,down,up,down,up,down,up,down,up
LLM 看到警告后会主动调整策略,比如按 A 与 NPC 对话,或按 Start 打开菜单查看状态。
7. 外挂记忆:AI 的笔记本
LLM 的上下文窗口是有限的。每一步只看到当前截图,不知道之前做了什么。为了解决这个问题,我们设计了一个 外部记忆系统——一个 markdown 文件,AI 可以随时查看和更新。
# 宝可梦火红 — AI 游戏笔记
## 当前进度
- 在真新镇大木博士实验室
## 当前队伍
- 杰尼龟 (AAAAAA) Lv.6 HP:22/22 水系
## 目标
- 前往常青市挑战第一个道馆
## 重要发现
[09:31] 大木博士实验室有四个精灵球,分别装着三种初始宝可梦
[09:45] 选了杰尼龟,水系克制第一个道馆的岩石系
class StateManager:
def __init__(self, memory_file="memory/journal.txt"):
self.memory_file = Path(memory_file)
def get_memory(self):
return self.memory_file.read_text(encoding="utf-8")
def append_memory(self, note):
ts = datetime.now().strftime("%H:%M")
with open(self.memory_file, "a", encoding="utf-8") as f:
f.write(f"\n[{ts}] {note}")
每一步,Agent 会:
- 读取笔记本内容
- 将其作为上下文传给 LLM
- LLM 输出的
NOTE字段会被追加到笔记本
这样 AI 就有了"长期记忆"——它知道自己之前选了什么宝可梦、打了哪些道馆、当前目标是什么。
8. 从截图到视频:完整录屏
Agent 运行时每一步都会保存截图(screenshots/step_000001.png...),最终我们用 ffmpeg 将 5000+ 张截图合成为视频:
def make_video(fps=6):
screenshots = sorted(Path("screenshots").glob("step_*.png"))
# 生成 ffmpeg concat 文件列表
list_file = Path("screenshots/filelist.txt")
with open(list_file, "w") as f:
for s in screenshots:
f.write(f"file '{s.absolute()}'\n")
f.write(f"duration {1/fps}\n")
f.write(f"file '{screenshots[-1].absolute()}'\n")
# 合成视频
os.system(
f"ffmpeg -y -f concat -safe 0 -i screenshots/filelist.txt "
f"-vf 'scale=480:432:flags=neighbor' "
f"-c:v libx264 -pix_fmt yuv420p -crf 20 "
f"pokemon_full_playthrough.mp4"
)
关键参数解释:
fps=6:每秒 6 帧(每步约 0.3-0.8 秒的游戏时间)scale=480:432:flags=neighbor:最近邻插值放大,保持像素风crf=20:视频质量参数(越小质量越高)
9. 8-bit 音乐生成:用代码演奏宝可梦主题曲
视频没有声音怎么行?我们从 pret/pokered 开源项目的 titlescreen.asm 中提取了宝可梦 Red 的主题曲音符数据,用 Python 生成了 8-bit chiptune 风格的 WAV 文件:
# 从 pokered 源码提取的音符数据
# channels[0] = 第一声道(主旋律)
# channels[1] = 第二声道(和声)
# channels[2] = 第三声道(低音)
def generate_square_wave(freq, duration, duty=0.5):
"""生成方波(Game Boy 音色的核心)"""
t = np.linspace(0, duration, int(SAMPLE_RATE * duration), False)
wave = np.where(np.sin(2 * np.pi * freq * t) > 0, 0.5, -0.5)
return wave * 0.3 # 降低音量
def note_to_freq(note_name):
"""将音符名转换为频率"""
notes = {'C4': 261.63, 'D4': 293.66, 'E4': 329.63,
'F4': 349.23, 'G4': 392.00, 'A4': 440.00, 'B4': 493.88}
return notes.get(note_name, 0)
生成过程:
- 解析
titlescreen.asm中的音符宏(m_note、m_rest等) - 将音符名转换为频率
- 用 numpy 生成方波(Game Boy 的经典音色)
- 混合三个声道
- 导出为 WAV 文件
最终生成了 pokemon_red_theme.wav(约 8MB),时长约 30 秒的完整主题曲。
视频+音乐合成:
ffmpeg -i pokemon_full_playthrough.mp4 \
-i music/pokemon_red_theme.wav \
-c:v copy -c:a aac -shortest \
pokemon_with_music.mp4
最终产出 pokemon_with_music.mp4(2.1MB,约 94 秒),画面是 AI 玩宝可梦的过程,背景音乐是经典的宝可梦主题曲。
10. 最终成果与数据
运行数据
| 指标 | 数值 |
|---|---|
| 总运行步数 | 5,000+ 步 |
| 截图数量 | 5,696 张 |
| 视频时长 | ~94 秒 |
| 视频大小 | 2.1 MB |
| 使用模型 | Claude Haiku 4.5 |
| API 调用次数 | ~5,000 次 |
| 模拟器速度 | 1x 实时 |
| 预处理 | 2x 放大 + 1.5x 对比度 |
游戏进度
AI 成功完成了以下游戏流程:
- 跳过开场 → 使用 PokemonRedExperiments 存档直接跳过
- 进入大木博士实验室 → 在真新镇户外走到实验室门口,按 A 进入
- 与大木博士对话 → 理解对话框中的文本,持续按 A 推进
- 选择初始宝可梦 → 选择了杰尼龟(水系),克制第一个道馆
- 探索真新镇 → 在户外区域自由移动
- 挑战道馆 → 进入常青市道馆,与训练家战斗
LLM 决策示例
从实际运行日志中截取的 AI 决策过程:
Step 0 | up | (11,12) map=40 | 我刚从房子出来,现在站在真新镇...
Step 2 | a | (11, 5) map=40 | 已经站在门口了,按 a 进入实验室...
Step 6 | a | (11, 3) map=40 | 进入实验室,需要和大木博士对话...
Step 10| a | ( 8, 4) map=37 | 大木博士在说话,按 a 继续...
Step 20| a | ( 5, 6) map=37 | 看到书架,"Crammed full of POKéMON books"...
Step 50| a | ( 6, 8) map=37 | 精灵球在桌子上,按 a 选择初始宝可梦...
Step 100| left | ( 6, 7) map=37 | 选完宝可梦,去找大木博士...
11. 经验总结与未来展望
关键经验
1. 模拟器选择至关重要
- PyBoy 是目前最好的 Python Game Boy 模拟器,但 v2 的 API 改动很大
- 无限速度看似高效,实则破坏游戏逻辑
- 预制作存档比自动跳过开场更可靠
2. 截图质量直接影响 LLM 决策
- 160x144 分辨率太小,必须放大
- 对比度增强有助于 LLM 识别 UI 元素
- 最近邻插值保持像素风格,比双线性插值效果好
3. 外部记忆是 Agent 的生命线
- LLM 上下文窗口有限,无法记住所有历史
- Markdown 笔记本简单但有效
- 让 AI 自己决定记什么比预定义模板更灵活
4. 防循环是必须的
- AI 最容易陷入的行为就是反复按同一个键
- 三重检测策略(连续相同、来回移动、注入警告)效果显著
- 警告信息直接注入 LLM 上下文比硬编码规则更智能
5. 多 Provider 支持的价值
- Claude Haiku 响应快、成本低,适合大量探索
- GPT-4o 在复杂场景理解上更强
- 不同模型在不同任务上各有优势
未来改进方向
- 碰撞检测修复:深入 PyBoy 源码,尝试修复 Pokemon Red 的碰撞问题
- 记忆系统升级:用向量数据库替代 markdown 文件,支持语义搜索
- 多模型协作:用 Haiku 做快速探索,遇到复杂决策时切换到 Claude Opus
- 自动存档管理:在关键节点自动保存游戏状态,支持失败回滚
- 可视化 Dashboard:实时显示 Agent 的决策过程和游戏状态
- 屏幕 OCR:增加专门的 OCR 模块,准确读取 HP、等级、技能等数值
写在最后
这个项目让我们深刻体会到:让 AI 玩游戏,最难的不是模型能力,而是工程细节。PyBoy 的碰撞检测、按键时序、存档格式……这些"不性感"的工程问题,往往决定了项目的成败。
但这也正是 AI Agent 的魅力所在——它需要跨越模型推理、模拟器控制、状态管理、错误处理等多个领域的知识边界,才能真正"玩"一个游戏。
最终,当我们在屏幕上看到 AI 控制的小角色走出真新镇、进入道馆、开始战斗时,那种成就感是无与伦比的。
AI 不仅能玩游戏——它能像人一样,看屏幕、做决策、犯错误、然后自己修正。