RenPy接入ChatGLM做恋爱选项

本文解决什么问题

做视觉小说、恋爱模拟原型时,最费脑的往往不是画面,而是”每个场景该给玩家哪几个选项”。手写分支越堆越多,还容易前后不一致。这篇教程要解决的就是:在 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 恋爱选项生成的数据流(好感度 + 设定 + 历史 → 模型 → 解析 → 菜单 → 回写历史)。

想让 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/,反而给打包和跨平台埋雷,不推荐。

坑三:接口超时把游戏卡死

同步请求会阻塞主线程,网络一慢画面就假死。处理:①给 urlopentimeouttry/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 模块的全局量。处理:凡是要跟存档走的状态(affectionchat_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.pyscript.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 参与范围为初稿撰写与代码草拟,版本号、接口地址、报错处理均经人工复核。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注