本文解决什么问题
做视觉小说、恋爱模拟原型时,最费脑的往往不是画面,而是”每个场景该给玩家哪几个选项”。手写分支越堆越多,还容易前后不一致。这篇教程要解决的就是:在 RenPy 里接入 ChatGLM 接口,让 AI 根据当前好感度实时生成恋爱对话选项,并落地成一个可运行的分支 Demo。
- 适合谁:会一点 RenPy 脚本、想给恋爱/乙女原型加”动态选项”的独立开发者。
- 不适合:想要开箱即用商业插件、或完全没碰过 Python 的读者。
- 你能拿到:
script.rpy+ai_client.py两个文件、示例 prompt、本地运行步骤,以及我实测踩到的五个坑及处理。
实验环境(作者已实机验证):RenPy 8.3.4(内置 Python 3.9)/ ChatGLM 开放平台 glm-4-flash 模型 / Windows 11 / 测试日期 2026-06。下文所有代码、报错、参数均在该环境跑通,版本敏感处已标注,换版本请自行复核。
准备 ChatGLM API Key 与 RenPy 项目结构
先到智谱 ChatGLM 开放平台(open.bigmodel.cn,访问日期 2026-06)注册并创建 API Key。接口地址为 https://open.bigmodel.cn/api/paas/v4/chat/completions,鉴权用 Authorization: Bearer <你的Key>。glm-4-flash 目前有免费额度,做原型足够。
关键安全原则:绝不把 Key 硬编码进 .rpy。RenPy 打包(distribute)时会把 game/ 目录下几乎所有文件塞进发行包,玩家解包就能看到明文 Key。开发期我用环境变量读取,正式上线则应通过你自己的服务器中转(游戏端只连你的代理,永远不下发真 Key)。
项目结构(RenPy 会把 game/ 目录加入 Python 搜索路径,因此 ai_client.py 放这里可直接 import):
你的项目/
├── game/
│ ├── script.rpy # 剧情脚本 + 菜单逻辑
│ └── ai_client.py # 纯 Python 的 ChatGLM 请求封装
└── ...
Windows 下临时设置环境变量(当前终端有效),再从这个终端启动 RenPy Launcher:
# PowerShell,仅当前会话有效
$env:CHATGLM_API_KEY = "你的Key"
# 然后从同一个终端打开 RenPy SDK 的 renpy.exe 启动器
步骤一:封装 HTTP 请求函数,按好感度生成 3 个选项
第一个大坑先说在前面:RenPy 8 内置的 Python 里没有 requests 库,直接 import requests 会报 ModuleNotFoundError。我改用标准库 urllib.request,零依赖、不用往包里塞第三方库。下面是 ai_client.py:
# game/ai_client.py —— 纯标准库,RenPy 8.3 / Python 3.9 实测可用
import os
import json
import urllib.request
import urllib.error
API_URL = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
def _post(payload, timeout=20):
"""向 ChatGLM 发一次非流式请求,返回解析后的 dict。"""
api_key = os.environ.get("CHATGLM_API_KEY", "")
if not api_key:
raise RuntimeError("没读到 CHATGLM_API_KEY,请检查环境变量")
headers = {
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
}
# 关键:ensure_ascii=False 后再按 utf-8 编码,否则中文会被转义/乱码
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(API_URL, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8") # 显式 utf-8 解码,别用默认编码
return json.loads(raw)
def gen_options(affection, npc_name, history, n=3):
"""按好感度让 AI 产出 n 个恋爱选项,返回字符串列表。失败时抛异常,交上层兜底。"""
system = (
"你是恋爱视觉小说的选项设计师。只输出一个 JSON 数组,"
"数组里是 %d 个中文对话选项字符串,不要任何多余解释、不要代码块。"
% n
)
user = (
"角色:%s。玩家当前对其好感度为 %d(范围 0-100,越高越亲密)。"
"请生成 %d 个玩家可对该角色说的话,语气随好感度变化。"
% (npc_name, affection, n)
)
messages = [{"role": "system", "content": system}]
messages.extend(history[-4:]) # 只带最近两轮,控制上下文长度
messages.append({"role": "user", "content": user})
data = _post({
"model": "glm-4-flash",
"messages": messages,
"temperature": 0.8,
})
text = data["choices"][0]["message"]["content"]
return _parse_options(text, n)
这里 _parse_options 单独抽出来,是因为大模型返回的 JSON 并不稳定,步骤二细说。
步骤二:把 AI 结果解析成 RenPy 菜单,并绑定好感度与兜底文案
模型有时乖乖返回 ["选项A","选项B","选项C"],有时会裹上 ```json 代码块,甚至多写一句”以下是选项:”。所以解析要多层兜底,任何一步失败都不能让游戏崩:
# game/ai_client.py 续
import re
FALLBACK = ["……(微笑着看向对方)", "我们聊点别的吧。", "(保持沉默)"]
def _parse_options(text, n):
text = text.strip()
# 1) 剥掉可能的 ```json ... ``` 外壳
text = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", text).strip()
# 2) 优先按 JSON 数组解析
try:
arr = json.loads(text)
if isinstance(arr, list) and arr:
opts = [str(x).strip() for x in arr if str(x).strip()]
if opts:
return (opts + FALLBACK)[:n]
except (json.JSONDecodeError, TypeError):
pass
# 3) 退化方案:按行拆,去掉行首的序号/符号
lines = [re.sub(r"^\s*[\d\.\-、)]+\s*", "", ln).strip()
for ln in text.splitlines() if ln.strip()]
lines = [ln for ln in lines if ln]
if lines:
return (lines + FALLBACK)[:n]
# 4) 全挂了就用兜底文案,保证菜单永远能显示
return FALLBACK[:n]
在 script.rpy 里用 renpy.display_menu 动态构造菜单,并把选择结果记进历史、按选项调整好感度:
# game/script.rpy
init python:
import ai_client # game/ 在搜索路径里,可直接 import
# 存档要能保留的变量,一律用 default 声明(这点很重要,见踩坑五)
default affection = 30
default chat_history = [] # 形如 [{"role":"user","content":"..."}, ...]
label ai_choice:
"林夏" "(她抬起头)今天……你想说点什么?"
python:
try:
options = ai_client.gen_options(affection, "林夏", chat_history)
except Exception as e:
# 网络/超时/解析异常,统一降级为静态选项,绝不卡死
renpy.log("AI 选项生成失败: %r" % e)
options = ["(笑而不语)", "我在想你。", "换个话题吧。"]
# 动态菜单:display_menu 接受 (文本, 返回值) 列表
picked = renpy.display_menu([(opt, opt) for opt in options])
# 记录本轮玩家选择,供下一轮当上下文
chat_history.append({"role": "user", "content": picked})
# 简单好感度规则:示例用长度当占位,真实项目应按标签判定
affection = min(100, affection + 2)
"林夏" "(她笑了笑)嗯,我记住了。"
return
display_menu 传入 (文本, 返回值) 的列表就能生成任意数量的选项,这是把 AI 输出接进 RenPy 菜单最省事的方式;比起写死 menu: 块,它能吃动态列表。
步骤三:加入简单上下文记忆
要让选项”记得前情”,把三样东西拼进 prompt:角色设定(system)+ 最近两轮对话(history)+ 当前好感度(user)。上面的 gen_options 已经用 history[-4:] 只截最近两轮(一轮=一问一答两条),避免上下文无限膨胀、token 越滚越贵。
好感度 affection ┐
角色设定 system ├─▶ 拼 messages ─▶ ChatGLM ─▶ JSON 选项 ─▶ 解析兜底 ─▶ RenPy 菜单
最近两轮 history ┘ │
▲ │
└──────────────── 玩家选择写回 chat_history ◀──────────────┘想让 AI 也能”回话”、形成多轮对话时,把它的回复同样 append 成 {"role":"assistant","content": ...} 存进 chat_history 即可。记忆的本质就是这条 messages 列表在存档里被完整保留。
真实踩坑与报错处理
坑一:中文乱码
最初直接 json.dumps(payload)(默认 ensure_ascii=True),中文被转成 \uXXXX;返回读取又没指定编码,界面显示成一堆问号。处理:请求侧 json.dumps(..., ensure_ascii=False).encode("utf-8"),响应侧 resp.read().decode("utf-8"),两头都锁死 utf-8,乱码消失。
坑二:requests 不可用
报 ModuleNotFoundError: No module named 'requests'——RenPy 内置 Python 没带它。处理:改用标准库 urllib.request(见上)。硬要用 requests 得手动放进 game/python-packages/,反而给打包和跨平台埋雷,不推荐。
坑三:接口超时把游戏卡死
同步请求会阻塞主线程,网络一慢画面就假死。处理:①给 urlopen 设 timeout 并 try/except urllib.error.URLError 兜底降级到静态选项;②进阶做法是用 renpy.invoke_in_thread(fn) 把请求丢到后台线程,配一个”思考中…”的等待画面,请求完成再刷新菜单。glm-4-flash 通常 1~2 秒返回,原型阶段同步+超时兜底已够用。
坑四:返回 JSON 格式不稳定
模型偶尔多包一层 ```json 或加解说词,直接 json.loads 会抛 JSONDecodeError。处理:就是步骤二的四层兜底——剥代码块 → 试 JSON → 按行拆序号 → 最后回退固定文案。加上 system 里明确”只输出 JSON 数组、不要代码块”,实测失败率明显下降。
坑五:存档后变量丢失
我一开始把对话历史存成 ai_client.py 里的模块级全局变量,结果 读档后历史全空。原因是 RenPy 存档只序列化它自己的 store,不会保存任意 Python 模块的全局量。处理:凡是要跟存档走的状态(affection、chat_history)一律用 default 在 store 里声明;而像 AI 客户端这种不可 pickle、也不该进存档的对象,放 init python: 里初始化(init 期的变量不进存档,读档不会把它冲掉),两边职责分清楚,存档就稳了。
关于流式文本的说明
本 Demo 生成”选项”用的是非流式请求——选项要一次性拿全再拆成菜单,流式反而添乱。流式("stream": true)更适合让 NPC 的对话正文逐字蹦出。若要接流式,注意三点:返回是 SSE,每行以 data: 开头需手动剥前缀;结束标志是 data: [DONE];分块可能把一个 UTF-8 中文字符切两半,得先按字节缓冲再解码,否则又是乱码。原型阶段建议先跑通非流式,稳定后再上流式。
小结 + 可复现完整代码
到这里,一个”按好感度动态生成恋爱选项”的 RenPy × ChatGLM 最小闭环就跑通了:urllib 发请求 → 多层兜底解析 → display_menu 动态出菜单 → 选择回写历史 → default 变量吃进存档。核心经验就五条踩坑:中文两头锁 utf-8、放弃 requests 用 urllib、超时必须兜底、JSON 解析要多层降级、存档状态一律用 default。
本文 ai_client.py 与 script.rpy 的全部代码已在上文分段给出,可直接复制到你本地 RenPy 8.3.4 项目的 game/ 目录运行;配好 CHATGLM_API_KEY 环境变量后,从同一终端启动 RenPy、跳到 ai_choice 标签即可看到效果。建议先用免费的 glm-4-flash 调通链路,再按自己的好感度规则和角色设定替换 prompt。
🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。测试环境:RenPy 8.3.4 / Python 3.9 / ChatGLM glm-4-flash / Windows 11 / 2026-06;文中代码由作者在该环境实机运行验证,AI 参与范围为初稿撰写与代码草拟,版本号、接口地址、报错处理均经人工复核。


