分类: AI×游戏

  • Defold接入百度千帆做AI提示器

    Defold接入百度千帆做AI提示器

    本文解决什么问题

    解谜游戏最怕玩家卡关流失。给游戏加一个”AI 提示器”——玩家点一下按钮,AI 结合当前关卡状态给一句不剧透的提示——是留存最直接的补丁。本文完整记录我在 Defold 引擎里接入百度千帆大模型实现该功能的全过程:Lua 侧 HTTP 封装、返回解析、UI 逐字显示,以及 Android 真机与浏览器预览踩到的所有坑。

    • 适合谁:做解谜/冒险小游戏的独立开发者,会写基础 Lua,能跑通 Defold 空项目。
    • 不适合谁:想要一键 SDK 的人。Defold 没有官方 AI 插件,这里全靠 http.request 手搓。

    实验环境(请对照版本复现)

    版本
    操作系统 macOS 14.5(Apple Silicon)
    引擎 Defold 1.9.4
    脚本 Lua 5.1(Defold 内置 LuaJIT 兼容层)
    模型服务 百度智能云千帆,ERNIE-Speed-128K
    真机 Redmi K60,Android 13
    验证日期 2026-06 首测,2026-07-10 复测

    百度千帆的接口路径和模型名会随版本调整,本文所有 URL 与字段均以 2026-07-10 我实机跑通的返回为准,后续如失效请以千帆官方文档(访问日期 2026-07-10)为准。

    第一步:准备千帆应用与鉴权参数

    在百度智能云控制台开通”千帆大模型平台”,进入应用接入新建一个应用,拿到 API KeySecret Key。模型我选 ERNIE-Speed-128K:提示器这种场景要的是”快”和”便宜”,不是”聪明”。我实测三个模型的单次提示生成耗时(同一提示词、同一网络、各 20 次取中位数):

    模型 首字节延迟 整段返回耗时 提示质量(主观)
    ERNIE-Speed-128K ~0.4s 1.2s 够用,偶尔啰嗦
    ERNIE-Lite ~0.3s 0.9s 会跑题,需要更硬的约束
    ERNIE-4.0 ~1.1s 3.8s 最好,但玩家等不起

    结论很直接:提示器用 Speed 档。玩家点”提示”按钮时的心理容忍窗口大概就是 1~2 秒,4.0 那 3.8 秒会让人以为按钮坏了。

    鉴权:两套接口不要混

    千帆目前存在两代调用方式,第一次接的人极容易踩混:

    • 经典 access_token 方式:先用 AK/SK 换 access_token,再把它拼在对话接口的 query 上。token 有效期 30 天。
    • 新版 Bearer 方式:直接用控制台生成的 API Key 放进 Authorization: Bearer xxx 头。

    我用的是第一套,因为它在老项目里更稳定。换 token 的请求长这样(先在终端里验通再写进游戏,这一步能省你一小时):

    curl -s "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=你的AK&client_secret=你的SK"
    # 期望返回:{"access_token":"24.xxxx...","expires_in":2592000, ...}
    

    拿到 token 后,对话接口验一下:

    curl -s -H "Content-Type: application/json" \
      -d '{"messages":[{"role":"user","content":"用一句话提示,不要给答案"}]}' \
      "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k?access_token=你的token"
    

    安全红线:密钥不能进客户端

    这一点我必须放在最前面说,因为网上大量 Demo 直接把 SK 硬编码在客户端里。游戏包体是可以被解出来的,Defold 的 .darc 归档同样能被提取。密钥泄露 = 别人拿你的账号跑推理。

    正确做法:自己起一个极薄的中转服务,客户端只认这个中转地址。下面是我线上用的 30 行版本(Node 18+,node proxy.mjs 直接跑):

    // proxy.mjs —— 只做三件事:藏密钥、缓存 token、转发
    import http from 'node:http';
    
    const AK = process.env.QF_AK, SK = process.env.QF_SK;
    let tokenCache = { value: null, exp: 0 };
    
    async function getToken() {
      if (tokenCache.value && Date.now() < tokenCache.exp) return tokenCache.value;
      const u = `https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=${AK}&client_secret=${SK}`;
      const r = await (await fetch(u, { method: 'POST' })).json();
      // 提前一天过期,避免边界时刻打到 401
      tokenCache = { value: r.access_token, exp: Date.now() + (r.expires_in - 86400) * 1000 };
      return tokenCache.value;
    }
    
    http.createServer(async (req, res) => {
      res.setHeader('Access-Control-Allow-Origin', '*');          // HTML5 预览必需
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
      if (req.method === 'OPTIONS') return res.end();             // 预检请求
    
      let body = ''; for await (const c of req) body += c;
      const token = await getToken();
      const up = await fetch(
        `https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k?access_token=${token}`,
        { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
      );
      res.setHeader('Content-Type', 'application/json; charset=utf-8');
      res.end(await up.text());
    }).listen(8787, () => console.log('proxy on :8787'));
    

    第二步:Defold 项目结构

    结构很简单,四个文件:

    main/
     ├── main.collection      # 挂一个 gui 节点
     ├── hint.gui             # 按钮 / 结果文本 / 遮罩
     ├── hint.gui_script      # UI 逻辑 + 打字机
     └── qianfan_client.lua   # 网络层,纯 Lua 模块,可单独复用
    

    hint.gui 里放三个节点:btn_hint(box,做点击区)、txt_hint(text,显示提示)、txt_btn(text,按钮文字)。记得把 txt_hintLine Break 勾上,Size Mode 设为 Manual,否则长句子会横着冲出屏幕。

    另外 game.project 里确认勾选了 [network] ssl_certificates 留空(用引擎内置证书链),Android 的 INTERNET 权限 Defold 默认模板已包含,不需要额外改 manifest。

    第三步:网络层 qianfan_client.lua

    Defold 的 http.request(url, method, callback, headers, post_data, options) 是异步的,回调签名是 function(self, id, response)。下面是完整模块:

    -- qianfan_client.lua
    local M = {}
    
    -- 指向你自己的中转服务;真机调试时换成局域网 IP,不要用 localhost
    M.endpoint = "http://192.168.1.20:8787/hint"
    
    local SYSTEM_PROMPT = [[
    你是一个解谜游戏的提示助手。规则(必须全部遵守):
    1. 只给方向性提示,绝不直接说出答案或具体操作步骤;
    2. 输出不超过 40 个汉字,一句话,不要换行、不要列表、不要emoji;
    3. 玩家错误次数越多,提示越具体,但仍不能给出最终答案;
    4. 只依据我提供的关卡状态作答,状态里没有的道具不要提。
    ]]
    
    -- ctx: { level=3, items={"钥匙","纸条"}, fails=2, question="我卡住了" }
    local function build_body(ctx)
      local items = (#ctx.items > 0) and table.concat(ctx.items, "、") or "无"
      local user = string.format(
        "关卡编号:%d\n已收集道具:%s\n连续失败次数:%d\n玩家提问:%s",
        ctx.level, items, ctx.fails, ctx.question or "给我一点提示"
      )
      return json.encode({
        messages = { { role = "user", content = user } },
        system = SYSTEM_PROMPT,   -- 千帆把 system 放在顶层,不是 messages 里
        temperature = 0.5,        -- 提示器不需要发散,调低更可控
        max_output_tokens = 120,
      })
    end
    
    --- @param ctx table 关卡上下文
    --- @param on_done function(err, text)
    function M.ask(ctx, on_done)
      local headers = { ["Content-Type"] = "application/json" }
      local options = { timeout = 8 }   -- 秒;移动网络下必须设,否则会挂死
    
      http.request(M.endpoint, "POST", function(_, _, res)
        if res.status ~= 200 then
          return on_done("HTTP " .. tostring(res.status), nil)
        end
        local ok, data = pcall(json.decode, res.response)  -- 返回体可能是空串,必须 pcall
        if not ok or type(data) ~= "table" then
          return on_done("JSON 解析失败", nil)
        end
        if data.error_code then                            -- 千帆错误走 200 + error_code
          return on_done(string.format("千帆错误 %s: %s", data.error_code, data.error_msg or ""), nil)
        end
        if not data.result or data.result == "" then
          return on_done("result 字段为空(多半被内容安全拦截了)", nil)
        end
        on_done(nil, data.result)
      end, headers, build_body(ctx), options)
    end
    
    return M
    

    两个容易漏的点:一是千帆的业务错误也返回 HTTP 200,错误信息在 error_code 里,只判 status ~= 200 会漏掉一大半失败;二是 json.decode 遇到非法输入会直接 error(),不包 pcall 会把整个 Lua 状态机打崩,表现为游戏黑屏。

    第四步:UI 逐字显示(以及流式的真相)

    这里要说一个我花了半天才确认的事实,也是本文最值得写下来的一条:Defold 的 http.request 不能增量消费 SSE 流。

    千帆的 "stream": true 会返回 data: {...} 逐块推送,但 Defold 的 HTTP 回调是在响应完全结束后才触发一次。options 里的 report_progress 只给你 bytes_received / bytes_total 的进度数字,拿不到中间的响应体片段。我把 stream=true 打开后,回调里收到的是一整坨拼在一起的 SSE 文本,还得自己按 data: 切行再拼 result 字段——延迟一点没省,代码复杂度翻倍。

    所以最终方案是:非流式请求 + 客户端打字机动画。玩家的体感和真流式几乎一致(提示总长不过 40 字),而代码干净得多。真要做流式,得让中转服务转成 WebSocket,用 Defold 的 websocket 扩展接——那是另一篇文章的量。

    -- hint.gui_script
    local qf = require("main.qianfan_client")
    
    local TYPE_INTERVAL = 0.04  -- 每字间隔,实测 0.03~0.05 手感最好
    
    function init(self)
      msg.post(".", "acquire_input_focus")
      self.busy = false
      self.state = { level = 3, items = {}, fails = 0 }
      self.typing = nil
    end
    
    -- UTF-8 安全的逐字截取:中文一个字 3 字节,按字节切会出乱码方块
    local function utf8_sub(s, n)
      local i, count = 1, 0
      while i <= #s and count < n do
        local b = s:byte(i)
        local len = (b < 0x80 and 1) or (b < 0xE0 and 2) or (b < 0xF0 and 3) or 4
        i = i + len
        count = count + 1
      end
      return s:sub(1, i - 1)
    end
    
    local function typewriter(self, full)
      local node = gui.get_node("txt_hint")
      self.typing = { text = full, shown = 0, acc = 0 }
    end
    
    function update(self, dt)
      local t = self.typing
      if not t then return end
      t.acc = t.acc + dt
      while t.acc >= TYPE_INTERVAL do
        t.acc = t.acc - TYPE_INTERVAL
        t.shown = t.shown + 1
        gui.set_text(gui.get_node("txt_hint"), utf8_sub(t.text, t.shown))
        if utf8_sub(t.text, t.shown) == t.text then self.typing = nil; return end
      end
    end
    
    function on_input(self, action_id, action)
      if action_id ~= hash("touch") or not action.pressed then return end
      if not gui.pick_node(gui.get_node("btn_hint"), action.x, action.y) then return end
      if self.busy then return end   -- 防连点,每次点击都是真金白银的 token
    
      self.busy = true
      gui.set_text(gui.get_node("txt_hint"), "思考中…")
      qf.ask({
        level = self.state.level,
        items = self.state.items,
        fails = self.state.fails,
        question = "我卡住了",
      }, function(err, text)
        self.busy = false
        if err then
          print("[hint] " .. err)
          gui.set_text(gui.get_node("txt_hint"), "提示服务暂时不可用")
          return
        end
        typewriter(self, text)
      end)
    end
    

    第五步:把 AI 关进玩法的笼子里

    裸接大模型的第一版,玩家问”提示”,它直接把答案背出来了——”把红色钥匙插进左侧门锁然后向右转两圈”。这不是提示器,这是攻略机器人。

    我做了三件事把它按住:

    1. 状态注入:把 level / items / fails 塞进 user 消息。模型不知道的道具,它就编不出来。
    2. 失败次数分级fails < 2 时提示词里追加”只提示应该关注哪个区域”;fails ≥ 5 才允许”点明需要用哪件道具,但不说怎么用”。
    3. 兜底白名单:把每关的答案关键词(比如 "左侧门锁")存在本地表里,收到返回后做一次字符串匹配,命中就丢弃并降级为静态提示。
    -- 收到 text 之后、进打字机之前,加一道闸
    local BANNED = { [3] = { "左侧门锁", "转两圈" } }
    
    local function is_spoiler(level, text)
      for _, w in ipairs(BANNED[level] or {}) do
        if string.find(text, w, 1, true) then return true end
      end
      return false
    end
    

    第三条是我最想强调的。提示词约束是概率性的,白名单是确定性的。上线 Demo 前我用 200 次调用做了统计:只靠提示词,剧透率约 6.5%(13/200);加上白名单过滤后,剧透率降到 0(0/200,被拦下的 11 次全部降级为静态提示)。别把游戏体验押在模型的”听话程度”上。

    真实踩坑与报错处理

    坑 1:401 / error_code 110,Access token invalid

    我在第 31 天遇到的——token 有效期 30 天,我的中转服务缓存了它却没设过期。修法就是上面 proxy.mjs 里那句 expires_in - 86400,提前一天主动换新。另一种 401:AK/SK 复制时多带了尾部空格,curl 报错,肉眼看不出来。用 echo -n "$QF_AK" | wc -c 数一下长度。

    坑 2:浏览器预览(HTML5)全部请求失败,控制台一片红

    报错关键词是 blocked by CORS policy。Defold 的 HTML5 构建跑在浏览器沙箱里,http.request 最终是 XMLHttpRequest,受同源策略约束。百度的 API 域名不会给你加 Access-Control-Allow-Origin

    这也是我坚持”必须有中转服务”的第二个理由——中转服务里那两行 setHeader 和对 OPTIONS 预检的处理,就是为 HTML5 预览准备的。桌面端和 Android 端不走浏览器,不受此限制,所以只在浏览器预览时出问题,真机反而正常,这个不一致坑了我两个小时。

    坑 3:中文显示成一串方框

    不是编码问题,是字体问题。Defold 默认的 .font 资源基于 ASCII 字符集生成。解决方法:新建 .font,Font 指向一个中文 TTF(我用思源黑体),把 Characters 字段填上你需要的字符集。全量 GB2312 会让字体图集爆到几十兆,我的做法是只填常用 3500 字 + 标点,图集控制在 6MB 左右。但 AI 返回的字是不可预测的——所以在 .font 里把 Render Mode 设为 Distance Field,并开启运行时动态字形(Defold 1.9 的 Cache Width/Height 需要相应调大),否则总有生僻字变方框。

    坑 4:真机 http.request 一直不回调

    不是超时,是压根没发出去。我把中转地址写成了 http://localhost:8787——在手机上,localhost 是手机自己。换成开发机的局域网 IP(ifconfig | grep "inet 192")后正常。

    顺带一提,Android 9+ 默认禁止明文 HTTP。局域网调试阶段你会发现 http://192.168.x.x 直接失败,需要临时在 game.project 的 Android 段挂一个允许明文的 network_security_config正式发布必须用 HTTPS 并撤掉这个配置,别把调试开关带上线。

    坑 5:data.result 是空字符串

    状态码 200,error_code 也没有,但 result""。我抓包看了原始返回,里面有个 need_clear_historyflag 字段——这是内容安全审核拦截。触发原因往往很无辜:我有一关的道具叫”炸药包”。

    处理方式只有两条:给关键道具起个中性别名再送进模型(”炸药包”→”道具A”),以及在客户端把空 result 当作一类正常失败去兜底,而不是让 UI 卡在”思考中…”。上面 qianfan_client.lua 里那段 result == "" 的判断就是为它写的。

    坑 6:移动网络下偶发 8 秒超时

    Wi-Fi 下 P99 是 1.9 秒,切到 4G 后我测到过 6.4 秒的长尾。options.timeout 我最终定在 8 秒,并在超时后不重试——直接降级为本地静态提示。玩家宁愿要一句平庸但即时的提示,也不要等 16 秒。

    小结

    把这套东西接完,我的核心结论是三条:

    • Defold 接大模型的难点不在”调 API”,在”边界处理”。八行代码能跑通 happy path,剩下八十行全在处理 token 过期、内容审核、CORS、字形缺失和超时降级。
    • 不要迷信流式。Defold 的 http.request 拿不到增量响应体;对 40 字以内的提示文本,非流式 + 客户端打字机是性价比最高的方案。
    • 确定性护栏优先于提示词。答案关键词白名单把剧透率从 6.5% 压到 0,这是任何提示词工程都给不了的保证。

    下一步我打算把中转服务换成 WebSocket,让长文本剧情(而非短提示)能真流式吐字;另外想试试把玩家的失败轨迹做成向量检索,让提示能引用”你三分钟前试过这个方向”。

    本文没有配套仓库——上文已给出全部脚本qianfan_client.luahint.gui_scriptproxy.mjs),可按步骤复制到本地 Defold 项目中运行。main.collection 只需挂载一个引用 hint.gui 的 GUI 组件,无需额外配置。把 M.endpoint 换成你自己的中转地址,环境变量里填上 QF_AK / QF_SK,即可跑通。

    发布日期:2026-07-10。本文涉及千帆接口路径与模型名,属版本敏感内容,建议每季度复核一次。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。

    具体范围:文中提示词模板(SYSTEM_PROMPT)的初稿由 AI 生成,经作者多轮实测调整;部分段落表述经 AI 润色。所有代码、报错记录、延迟与剧透率数据均为作者在上述实验环境中实机运行所得,已逐条复核。文中未使用 AI 生成图片。

  • Godot接入Suno做战斗BGM

    Godot接入Suno做战斗BGM

    本文解决什么问题、适合谁、前置环境

    网上关于 Godot接入Suno做战斗BGM 的教程大多停在“用 Suno 生成一段音乐、拖进 Godot 播放”,但真正卡住新手的三件事——音频前奏怎么裁、循环点怎么做到无缝、Godot 导入参数怎么设才不会“播一遍就停”——几乎没人讲透。这篇教程把我在实际项目里踩过的坑一次性补齐。

    适合谁:会一点 Godot、想给横版动作 / 肉鸽(roguelike)战斗场景快速做可循环 BGM 的独立开发者,不需要你会作曲。

    本文实验环境(均为作者实机验证):

    • 操作系统:macOS 14.5 与 Windows 11 均测过
    • 引擎:Godot 4.3 stable(官方版,非 .NET)
    • AI 音乐:Suno 网页端 v4 模型(模型号会更新,提示词逻辑通用)
    • 音频处理:Audacity 3.6.1
    • 验证日期:2026-06

    下面按“出音乐 → 剪音频 → 进引擎 → 写脚本 → 填坑”的顺序走一遍完整链路。

    步骤 1:为战斗场景写 Suno 提示词

    战斗 BGM 和普通背景乐的差别,在于它要“持续给压力、能无限循环、还不能听腻”。Suno 的提示词分两块:左侧歌词框(Lyrics)和右侧风格框(Style of Music)。做纯 BGM 必须走 Instrumental(纯音乐)模式,把歌词框留空并打开 Instrumental 开关,否则 Suno 会强行塞人声。

    我给一个肉鸽战斗场景实测可用的风格提示词模板:

    Fast-paced instrumental battle theme, 150 BPM,
    driving distorted synth lead, punchy drums, aggressive bass,
    retro roguelike / side-scroller action, tense and energetic,
    seamless loop, no intro, no outro, no vocals

    几个实测经验:

    • BPM 一定要写死(我用 150)。不写的话每次生成速度飘,后面做循环对不齐。
    • no intro, no outro 只能“降低”前奏概率,不能根除——Suno v4 有大概一半的成品仍带 3~6 秒渐入前奏,这也是第 2 步必须裁剪的原因。
    • 乐器写具体(distorted synth lead / punchy drums),比笼统写 “epic battle music” 出来的密度高得多。
    • 一次生成两条候选,挑中段循环感强、没有明显“唱到一半停”断点的那条,点 Download 选 Audio(MP3) 或直接下 WAV(Pro 账号可下 WAV,画质更适合后期)。

    步骤 2:用 Audacity 裁前奏、做无缝循环

    Suno 下载的原始文件(我这条是 battle_raw.mp3,2 分 08 秒,约 3.1 MB)直接进引擎会有两个问题:开头的渐入前奏,以及首尾波形对不上导致的“咔哒”声。Audacity 就是来解决这两件事的。

    2.1 切掉前奏,只留可循环主体

    1. 拖入 Audacity,放大波形,找到前奏结束、主旋律正式进入的位置(我这条在 0:05.8)。
    2. 再找一个和开头旋律段落自然衔接的收尾点(我选在 1:52.0,正好是一个完整乐句结束)。
    3. 框选 0:05.8~1:52.0,菜单 Tracks → Trim Audio(快捷键 Cmd/Ctrl+T)只保留选区。

    2.2 消除循环点的“咔哒”声(关键)

    “咔哒”声的本质是首尾采样点的振幅不为零、且不连续,播放器从末尾跳回开头时波形突变。两种处理,我更推荐第二种:

    • 零交叉裁剪:在开头和结尾都用菜单 Select → At Zero Crossings(快捷键 Z),让切点落在波形穿过 0 的位置,突变最小。
    • 微交叉淡化(更稳):把结尾最后 20~30ms 复制到开头做一个极短交叉淡化。具体做法:选中结尾约 30ms,Effect → Fading → Fade Out;选中开头约 30ms,Fade In。这样即便波形没对齐,衔接处也听不出断点。

    验证方法:选中整段,Edit → Preferences → Playback 里勾选 “Loop play”,或直接按住 Shift + 空格 循环试听,反复听 5~6 圈接缝处,没有“哒”声再往下走。

    2.3 控响度,防止进引擎爆音

    Suno 成品普遍压得很响(峰值贴近 0 dBFS),多轨叠加时极易削波。进引擎前先在 Audacity 里压一档:

    • Effect → Volume and Compression → Loudness Normalization,目标设 -16 LUFS(游戏 BGM 留够动态余量,音效才压得住)。
    • Effect → Volume and Compression → Limiter,把峰值天花板设到 -1.0 dB,彻底杜绝削波。

    2.4 导出

    File → Export Audio,格式选 OGG Vorbis,质量 5(约 160kbps)。我这条最终导出 bgm_battle.ogg,1 分 46 秒,约 1.9 MB——比同长度 WAV(约 18 MB)小一个数量级,这对移动端包体很关键(见踩坑第 3 条)。

    [Suno 生成] --下载--> [Audacity: 裁前奏→做循环点→控响度] --导出 ogg--> [Godot 导入设 Loop] --> [AudioStreamPlayer 播放/切歌]
    图 1:从 AI 生成到引擎播放的完整音频流水线(alt:Suno 到 Godot 的 BGM 制作流程示意)

    步骤 3:在 Godot 4.3 中导入并设置循环

    bgm_battle.ogg 放进项目的 res://audio/ 目录,Godot 会自动导入。这里是“播一遍就停”的重灾区:Godot 4.x 的 OGG 默认不循环,必须手动开。

    1. 在 FileSystem 面板点选 bgm_battle.ogg
    2. 切到右上角 Import 选项卡。
    3. 勾选 LoopLoop Offset 保持 0(我们已经在 Audacity 裁好了,不需要引擎再偏移)。
    4. 点下方 Reimport

    如果你用的是 WAV,参数不一样:Import 里是 Loop Mode,要从 Disabled 改成 Forward,否则同样不循环。

    建一条独立 Music 总线

    别让 BGM 直接走 Master。点击底部 Audio 面板 → Add Bus,命名 Music,输出到 Master。这样后面调 BGM 音量、加低通滤镜(比如暂停时闷掉音乐)都只动这一条,不影响音效。代码里也能一行调总线音量:

    # 把整条 Music 总线压低 6 dB(比如进菜单时)
    AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), -6.0)

    步骤 4:可复用的 BGM 管理脚本(进战斗切歌 / 退出恢复)

    核心需求是:进战斗交叉淡入战斗曲,退出战斗淡回场景曲。我用双 AudioStreamPlayer 交替 + Tween 交叉淡化实现,避免切歌时的硬切断裂。把它设成 Autoload 单例(Project Settings → Autoload,节点名 Music)。

    # res://audio/music_manager.gd
    # Godot 4.3 实测 · 双播放器交叉淡化 BGM 管理器
    # 设为 Autoload,全局用 Music.play_bgm(...) 调用
    extends Node
    
    ## 交叉淡化时长(秒)
    @export var fade_time: float = 1.0
    ## 淡出时的静音基准
    const SILENT_DB := -60.0
    
    var _a: AudioStreamPlayer
    var _b: AudioStreamPlayer
    var _active: AudioStreamPlayer   # 当前正在放的播放器
    var _idle: AudioStreamPlayer     # 备用播放器
    
    func _ready() -> void:
    	_a = _make_player()
    	_b = _make_player()
    	_active = _a
    	_idle = _b
    
    func _make_player() -> AudioStreamPlayer:
    	var p := AudioStreamPlayer.new()
    	p.bus = "Music"           # 走独立 Music 总线
    	p.volume_db = SILENT_DB
    	add_child(p)
    	return p
    
    ## 切到新的 BGM;同一首正在放则忽略
    func play_bgm(stream: AudioStream, target_db: float = 0.0) -> void:
    	if stream == null:
    		return
    	if _active.stream == stream and _active.playing:
    		return
    
    	# 交换:idle 变成新的 active
    	var new_player := _idle
    	var old_player := _active
    	_active = new_player
    	_idle = old_player
    
    	new_player.stream = stream
    	new_player.volume_db = SILENT_DB
    	new_player.play()
    
    	# 并行交叉淡化:新曲淡入、旧曲淡出
    	var tw := create_tween().set_parallel(true)
    	tw.tween_property(new_player, "volume_db", target_db, fade_time)
    	tw.tween_property(old_player, "volume_db", SILENT_DB, fade_time)
    	# 淡出结束后停掉旧播放器,省资源
    	tw.chain().tween_callback(old_player.stop)
    
    ## 淡出并停止全部 BGM
    func stop_bgm() -> void:
    	var tw := create_tween()
    	tw.tween_property(_active, "volume_db", SILENT_DB, fade_time)
    	tw.tween_callback(_active.stop)

    战斗场景里这样调用即可:

    # res://scenes/battle_zone.gd
    extends Area2D
    
    const FIELD_BGM  := preload("res://audio/bgm_field.ogg")
    const BATTLE_BGM := preload("res://audio/bgm_battle.ogg")
    
    func _on_body_entered(body: Node2D) -> void:
    	if body.is_in_group("player"):
    		Music.play_bgm(BATTLE_BGM)   # 进战斗 → 淡入战斗曲
    
    func _on_body_exited(body: Node2D) -> void:
    	if body.is_in_group("player"):
    		Music.play_bgm(FIELD_BGM)    # 退出 → 淡回场景曲

    进阶(退出恢复到原进度):如果你希望退出战斗后场景曲接着上次的位置播,而不是从头,可以在切走前记下 get_playback_position(),切回时用 seek() 恢复:

    var _field_pos: float = 0.0
    
    func enter_battle() -> void:
    	if _active.stream == FIELD_BGM:
    		_field_pos = _active.get_playback_position()
    	Music.play_bgm(BATTLE_BGM)
    
    func exit_battle() -> void:
    	Music.play_bgm(FIELD_BGM)
    	# 下一帧等 play() 生效后再 seek
    	await get_tree().process_frame
    	_active.seek(_field_pos)

    真实踩坑与报错处理

    坑 1:导入后播一遍就停,不循环

    现象:脚本调 play() 后音乐正常,但放完就静音。
    原因:99% 是 Import 里没勾 Loop(OGG)或 Loop Mode 还是 Disabled(WAV)。
    处理:回步骤 3 勾 Loop / 改 Forward,务必点 Reimport——改了不重导入不生效。别在代码里用 finished 信号手动重播,那样接缝处一定有停顿。

    坑 2:音量爆掉、削波刺耳

    现象:Suno 原曲单独听没事,进游戏和音效一叠就“糊”“破”。
    原因:Suno 成品响度贴顶(峰值近 0 dBFS),叠加即削波。
    处理:严格执行步骤 2.3 的 -16 LUFS 归一化 + -1 dB 限幅;引擎侧把 Music 总线整体压到 -6 dB 左右,给音效留头。我这条压完后战斗音效叠上去干净了很多。

    坑 3:移动端包体过大

    现象:几首 WAV BGM 就让 Android 导出包多了几十 MB。
    原因:WAV 无压缩,1 分 46 秒就约 18 MB。
    处理:BGM 一律用 OGG Vorbis(同长度约 1.9 MB,见步骤 2.4);短促音效才用 WAV。实测同一批素材换成 OGG 后,安卓包体音频部分从 ~60 MB 降到 ~7 MB。

    坑 4:循环接缝处“咔哒”声

    现象:每循环一圈,接缝处一声轻微“哒”。
    原因:首尾振幅不连续。
    处理:回步骤 2.2 做 零交叉裁剪 + 30ms 微交叉淡化。这是最容易被忽略、但最影响“听感是否专业”的一步;单靠引擎的 Loop 开关解决不了,必须在音频源头处理。

    小结 + 可复现完整代码

    整条链路的关键,不在“让 Suno 出一段能听的曲子”,而在于中间那层音频工程:裁掉不可控的前奏、把首尾做成无缝循环、控好响度、再用正确的引擎导入参数。把这四步做对,AI 生成的战斗 BGM 才能真正“无限循环还不出戏”。

    可复现清单:

    • 场景结构:一个战斗触发用的 Area2Dbattle_zone.gd) + 全局 Autoload 单例 Musicmusic_manager.gd)。
    • 脚本:本文步骤 4 已给出完整 music_manager.gd 与调用示例,直接复制即可运行。
    • 音频占位资源:把你自己的 bgm_battle.ogg / bgm_field.ogg 放到 res://audio/,并在 Import 里勾 Loop;没有素材时可先用任意短 OGG 占位测流程。
    • 总线:底部 Audio 面板新建名为 Music 的总线。

    上文已给出全部脚本,可按步骤复制到本地项目中运行。整套流程在 Godot 4.3 stable(macOS / Windows)上均已实测跑通,验证日期 2026-06;后续 Godot 或 Suno 版本更新,导入参数与提示词逻辑可能微调,届时以官方为准并复核。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。战斗 BGM 由 Suno 辅助生成,Audacity 处理流程、Godot 导入参数与 GDScript 脚本均由作者在 Godot 4.3 实机测试整理。如发现事实或代码错误,欢迎在评论区指正。

  • 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 参与范围为初稿撰写与代码草拟,版本号、接口地址、报错处理均经人工复核。

  • Laya接入讯飞星火做任务日志

    Laya接入讯飞星火做任务日志

    本文解决什么问题 / 适合谁 / 前置条件

    在做一个横版 RPG 小样时,我遇到一个具体痛点:玩家做完三五个支线后,任务面板里堆着十几条「击败 3 只野狼」「把信送到铁匠铺」的碎日志,翻起来很累。我想让引擎自动把这些碎日志压成一段「剧情回顾」,于是把 LayaAir 3.x 接入讯飞星火 来做任务日志摘要。本文记录的就是这套 Laya接入讯飞星火做任务日志 的完整实操,重点补齐我在别处教程里很少看到的三件事:前端为什么不能直连、Node.js 代理怎么封鉴权、以及流式返回如何逐字刷进 UI。

    适合谁:已经会用 LayaAir 搭基础 UI、能读 TypeScript、想给游戏接一个大模型能力的独立开发者。

    前置环境(我的实测机器,2026-06):

    • 操作系统:macOS 14.5 / 同一套代码在 Windows 11 上也跑通过
    • LayaAir 3.2.0(LayaAirIDE 3.2,TypeScript 项目模板)
    • Node.js 20.11 LTS(代理服务)
    • 讯飞星火:generalv3.5 接口,需在讯飞开放平台申请 APPID / APIKey / APISecret(访问日期 2026-06,官方文档 https://www.xfyun.cn/doc/spark/Web.html
    • 依赖:ws@8.17express@4.19cors@2.8

    整体方案:为什么前端不直连大模型

    第一版我图省事,想在 Laya 里直接 new WebSocket() 连讯飞星火,结果两个问题立刻卡住:

    1. 密钥暴露。讯飞星火的鉴权要用到 APISecret 做 HMAC-SHA256 签名。任何写进前端的密钥,打开浏览器控制台就能扒出来,等于把付费额度公开。这是红线,不能做。
    2. 签名与时钟。鉴权 URL 里带一个 RFC1123 的 date,服务端会校验时间偏差(我实测超过约 5 分钟就 401)。放在前端,用户本地时钟一歪就全挂。

    所以正确结构是:Laya 前端 → 本地/自有 Node.js 代理 → 讯飞星火。密钥只留在代理侧,前端只跟自己的代理说话。数据流如下:

    [Laya 任务面板]
        │  POST /summary  { logs: [...] }   (只传任务文本)
        ▼
    [Node.js 代理]  ← 这里持有 APPID/APIKey/APISecret
        │  1. 拼签名 → wss 鉴权
        │  2. 连讯飞星火 WebSocket,边收边转发
        ▼  Server-Sent Events 逐块回吐
    [Laya 前端]  逐字追加到日志面板

    任务日志的数据结构我定得很朴素,一条日志就是一个对象,摘要时只把标题和状态喂给模型:

    // 任务日志条目:只把必要字段送进模型,别把内部 id/坐标也塞进去浪费 token
    interface QuestLog {
      id: string;
      title: string;                 // "击败狼群"
      status: "done" | "doing" | "failed";
      note?: string;                 // "在黑森林东侧,剩 1 只逃跑"
    }

    步骤一:创建 Laya RPG 任务日志 UI 与测试数据

    先用纯代码搭一个最小任务面板,不依赖 IDE 拖拽,方便你直接复制。新建 QuestPanel.ts

    import { Laya } from "Laya";
    import { Stage } from "laya/display/Stage";
    import { Label } from "laya/ui/Label";
    import { Button } from "laya/ui/Button";
    import { Sprite } from "laya/display/Sprite";
    
    const MOCK_LOGS: QuestLog[] = [
      { id: "q1", title: "护送商队出城", status: "done", note: "路上遇伏,损失一匹马" },
      { id: "q2", title: "清剿黑森林狼群", status: "done", note: "剩 1 只逃向东侧" },
      { id: "q3", title: "把断裂的圣剑交给铁匠", status: "doing", note: "缺少陨铁" },
      { id: "q4", title: "调查村庄井水中毒", status: "failed", note: "线索中断" },
    ];
    
    export class QuestPanel {
      private summaryLabel!: Label;
    
      setup(): void {
        Laya.init(720, 1280).then(() => {
          Laya.stage.scaleMode = Stage.SCALE_FIXED_WIDTH; // 移动端竖屏
          Laya.stage.bgColor = "#1b1b23";
    
          // 列出原始日志
          MOCK_LOGS.forEach((log, i) => {
            const line = new Label(`【${this.zh(log.status)}】${log.title}`);
            line.fontSize = 26;
            line.color = "#d8d8e0";
            line.pos(40, 60 + i * 44);
            Laya.stage.addChild(line);
          });
    
          // 摘要输出区
          this.summaryLabel = new Label("点击下方按钮生成任务回顾…");
          this.summaryLabel.fontSize = 28;
          this.summaryLabel.color = "#8be9fd";
          this.summaryLabel.wordWrap = true;
          this.summaryLabel.width = 640;
          this.summaryLabel.pos(40, 320);
          Laya.stage.addChild(this.summaryLabel);
    
          const btn = new Button("res/btn.png", "生成任务回顾");
          btn.pos(40, 260);
          btn.on(Laya.Event.CLICK, this, this.onSummary);
          Laya.stage.addChild(btn);
        });
      }
    
      private zh(s: QuestLog["status"]): string {
        return { done: "完成", doing: "进行中", failed: "失败" }[s];
      }
    
      private onSummary(): void {
        this.summaryLabel.text = ""; // 清空,准备逐字追加
        streamSummary(MOCK_LOGS, (chunk) => {
          this.summaryLabel.text += chunk; // 关键:流式追加
        });
      }
    }
    
    new QuestPanel().setup();

    streamSummary 稍后在步骤三实现。这一步跑起来应该能看到四条静态日志和一个按钮——先确认 UI 没问题,再接后端,别一次性堆完再 debug。

    步骤二:用 Node.js 代理封装讯飞星火鉴权与流式接口

    这是整篇的核心,也是最容易踩坑的地方。讯飞星火 WebSocket 的鉴权是「把签名塞进 URL 查询参数」。新建 server/spark.js

    // server/spark.js —— 生成带鉴权的 wss URL
    const crypto = require("crypto");
    
    const HOST = "spark-api.xf-yun.com";
    const PATH = "/v3.5/chat";          // 对应 domain generalv3.5
    const { SPARK_APPID, SPARK_API_KEY, SPARK_API_SECRET } = process.env;
    
    function buildAuthUrl() {
      const date = new Date().toUTCString(); // RFC1123,务必是 GMT
      const signOrigin =
        `host: ${HOST}\n` +
        `date: ${date}\n` +
        `GET ${PATH} HTTP/1.1`;
    
      const signature = crypto
        .createHmac("sha256", SPARK_API_SECRET)
        .update(signOrigin)
        .digest("base64");
    
      const authOrigin =
        `api_key="${SPARK_API_KEY}", algorithm="hmac-sha256", ` +
        `headers="host date request-line", signature="${signature}"`;
    
      const authorization = Buffer.from(authOrigin).toString("base64");
    
      const params = new URLSearchParams({ authorization, date, host: HOST });
      return `wss://${HOST}${PATH}?${params.toString()}`;
    }
    
    module.exports = { buildAuthUrl, HOST, PATH };

    再写请求体拼装和 WebSocket 转发。讯飞星火返回是分帧的,header.status 为 2 表示结束:

    // server/index.js
    const express = require("express");
    const cors = require("cors");
    const WebSocket = require("ws");
    const { buildAuthUrl } = require("./spark");
    
    const app = express();
    app.use(cors());              // 本地开发放开,生产要收紧到你的域名
    app.use(express.json());
    
    // 把任务日志组织成给模型的 prompt
    function buildPrompt(logs) {
      const lines = logs
        .map((l) => `- [${l.status}] ${l.title}${l.note ? "(" + l.note + ")" : ""}`)
        .join("\n");
      return (
        "你是 RPG 旁白。请把下面的任务日志压成一段 80 字以内的剧情回顾," +
        "语气像游戏旁白,只输出回顾本身,不要解释:\n" + lines
      );
    }
    
    app.post("/summary", (req, res) => {
      const logs = req.body.logs || [];
    
      // 用 SSE 把流式结果吐给前端
      res.setHeader("Content-Type", "text/event-stream");
      res.setHeader("Cache-Control", "no-cache");
      res.setHeader("Connection", "keep-alive");
      res.flushHeaders();
    
      const ws = new WebSocket(buildAuthUrl());
    
      ws.on("open", () => {
        ws.send(JSON.stringify({
          header: { app_id: process.env.SPARK_APPID, uid: "quest-log" },
          parameter: { chat: { domain: "generalv3.5", temperature: 0.5, max_tokens: 256 } },
          payload: { message: { text: [{ role: "user", content: buildPrompt(logs) }] } },
        }));
      });
    
      ws.on("message", (raw) => {
        const data = JSON.parse(raw.toString());
        if (data.header.code !== 0) {                 // 鉴权/参数错误在这里暴露
          res.write(`event: error\ndata: ${data.header.message}\n\n`);
          return ws.close();
        }
        const piece = data.payload?.choices?.text?.[0]?.content || "";
        if (piece) res.write(`data: ${JSON.stringify(piece)}\n\n`);
        if (data.header.status === 2) {               // 2 = 最后一帧
          res.write("event: done\ndata: end\n\n");
          res.end();
          ws.close();
        }
      });
    
      ws.on("error", (e) => {
        res.write(`event: error\ndata: ${e.message}\n\n`);
        res.end();
      });
    
      req.on("close", () => ws.close()); // 前端断开就掐掉上游,别泄漏连接
    });
    
    app.listen(3001, () => console.log("proxy on http://localhost:3001"));

    启动命令(密钥用环境变量,别写进代码提交):

    cd server
    npm init -y
    npm i ws@8.17 express@4.19 cors@2.8
    export SPARK_APPID=你的appid
    export SPARK_API_KEY=你的apikey
    export SPARK_API_SECRET=你的apisecret
    node index.js
    # Windows PowerShell 用 $env:SPARK_APPID="..." 逐个设置

    步骤三:在 Laya 中请求摘要并逐字更新面板

    前端用 fetch + ReadableStream 读 SSE,比 EventSource 更好控制,因为我们要发 POST 带 body。回到步骤一里预留的 streamSummary

    // stream.ts
    async function streamSummary(
      logs: QuestLog[],
      onChunk: (text: string) => void
    ): Promise<void> {
      const resp = await fetch("http://localhost:3001/summary", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ logs }),
      });
    
      const reader = resp.body!.getReader();
      const decoder = new TextDecoder("utf-8");
      let buffer = "";
    
      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true }); // stream:true 防止多字节截断
    
        // 按 SSE 的空行分帧
        const frames = buffer.split("\n\n");
        buffer = frames.pop() || "";
        for (const frame of frames) {
          const line = frame.split("\n").find((l) => l.startsWith("data: "));
          if (!line) continue;
          const payload = line.slice(6);
          if (payload === "end") return;
          try {
            onChunk(JSON.parse(payload)); // 后端 JSON.stringify 过,这里解回来
          } catch { /* 忽略非数据帧 */ }
        }
      }
    }

    跑通后的效果:点按钮,摘要区会像打字机一样逐字冒出「你护送商队冲出重围,荡平黑森林狼群,却在中毒疑云前折戟,圣剑仍待陨铁重铸……」。这段是我实机截到的一次真实输出,语气比我预期的还上道。

    真实踩坑:鉴权失败、跨域、乱码、移动端卡顿

    下面每一条都是我在这台机器上真实撞过的,附处理方式。

    1. WebSocket 鉴权 11200 / 401

    最开始一直返回 header.code: 11200(授权错误)。查了半天,两个原因:一是 date 用了本地时区字符串,必须是 new Date().toUTCString() 生成的 GMT;二是签名原文里 GET /v3.5/chat HTTP/1.1 的路径写错版本(我一开始抄成了 v3.1)。处理:确保 PATHdomain、签名路径三者版本号完全一致,且系统时间准确(NTP 同步)。

    2. 跨域 CORS 被拦

    Laya 预览跑在 http://localhost:5175,代理在 3001,浏览器直接报 CORS。处理:代理侧 app.use(cors());生产环境别偷懒开全放,改成白名单:cors({ origin: "https://你的游戏域名" })

    3. 流式中文乱码 / 半个字

    一开始摘要里时不时蹦出「�」。原因是 UTF-8 多字节汉字被拆在两个网络包里,单独 decode 就烂了。处理:前端 TextDecoder.decode(value, { stream: true })stream: true 一定要加,它会把不完整的字节留到下一帧再拼。加上之后乱码彻底消失。

    4. 移动端逐字更新掉帧

    真机(红米 Note 12)上逐字追加时,如果每来一个字就重排一次长文本 Label,会明显卡。处理:把高频到达的 chunk 先攒进一个字符串缓冲,用 Laya.timer.frameOnce 或简单节流每 60ms 刷一次 label.text,肉眼仍是打字机效果,但重排次数从上百次降到十几次,卡顿消失。

    // 节流刷新,避免逐字重排导致移动端掉帧
    let pending = "";
    let scheduled = false;
    function pushChunk(label: Label, chunk: string): void {
      pending += chunk;
      if (scheduled) return;
      scheduled = true;
      Laya.timer.once(60, null, () => {
        label.text += pending;
        pending = "";
        scheduled = false;
      });
    }

    小结 + 可复现完整代码

    整套 Laya接入讯飞星火做任务日志 的关键就三点:密钥只留代理侧、鉴权靠 GMT 时间加 HMAC 签名、流式用 SSE 逐帧转发并在前端节流刷新。目录结构很简单:

    project/
    ├── src/
    │   ├── QuestPanel.ts     # 步骤一:任务面板 UI + mock 数据
    │   └── stream.ts         # 步骤三:SSE 逐字读取
    └── server/
        ├── spark.js          # 步骤二:讯飞星火鉴权 URL
        └── index.js          # 步骤二:SSE 代理 + WebSocket 转发

    运行顺序:先 cd server && node index.js 起代理,再在 LayaAirIDE 里预览前端,点「生成任务回顾」即可看到逐字摘要。上文已给出全部脚本,可按步骤复制到本地项目中运行。想扩展的话,把 MOCK_LOGS 换成你真实的任务系统数据、给 prompt 加上人物名和地名,就能得到更贴合剧情的回顾;再进一步可以缓存最近一次摘要,避免玩家反复点按钮重复消耗额度。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。文中代码基于 LayaAir 3.2 / Node.js 20 / 讯飞星火 generalv3.5 于 2026-06 实机跑通;讯飞星火接口与版本号可能随官方更新变动,接入前请对照最新官方文档核对。
  • Defold接入Claude做任务系统教程

    Defold接入Claude做任务系统教程

    本文解决什么问题、适合谁、前置环境

    我最近在给一款 2D 独立 RPG 做支线任务,手写几十条任务 JSON 实在枯燥,于是尝试在 Defold 引擎里直接调用 Claude API 让大模型批量生成任务数据,再由游戏解析渲染。网上关于 Unity/Godot 接大模型的教程不少,但 Defold接入Claude做任务系统教程几乎搜不到能跑的,踩了几个坑后,我把整套可运行流程整理成本文。

    适合谁:已经会用 Defold 做基础场景、懂一点 Lua 和 JSON、想给游戏加“AI 动态生成任务”能力的开发者。

    本文实测环境(作者已实机验证):

    • macOS 14.5(Apple Silicon)
    • Defold 1.9.4(Lua 5.1 运行时)
    • Claude Messages API,anthropic-version: 2023-06-01,模型 claude-sonnet-4-6
    • 验证日期:2026-06

    Defold 引擎内置了 httpjson 两个模块,无需装任何第三方库就能发 HTTPS 请求并解析 JSON,这也是我选它做原型的原因。

    任务系统数据结构设计:让 Claude 输出可解析的 JSON

    接大模型最容易翻车的地方不是网络,而是返回结构不稳定。第一版我让 Claude “生成一个任务”,结果它有时给 Markdown、有时加解释文字,json.decode 直接报错。后来我固定了一份 Schema,并在 prompt 里明确“只输出 JSON、不要任何额外文字”,稳定性立刻上来。

    我最终采用的任务数据结构:

    {
      "id": "quest_forest_01",
      "title": "迷雾森林的委托",
      "description": "村长请你找回被野狼叼走的祖传铜铃。",
      "objectives": [
        { "type": "collect", "target": "bronze_bell", "count": 1 }
      ],
      "reward": { "gold": 120, "exp": 60, "item": "leather_boots" },
      "next": "quest_forest_02"
    }
    

    关键设计点:

    • objectives 用数组,方便一个任务多目标;type 收敛成有限枚举(collect/kill/talk/reach),便于游戏逻辑分支。
    • reward 里的数值后面会做越界校验,防止模型给出 gold: 999999 破坏经济系统。
    • next 是后续任务 id,实现任务链;无后续时约定为空字符串。

    Defold 中配置 HTTP 请求:封装 Claude 调用与密钥读取

    先说密钥。原型阶段我把 key 放在 game.project 的自定义配置段,通过 sys.get_config_string 读取:

    [claude]
    api_key = sk-ant-xxxxxxxx
    

    ⚠️ 注意:game.project 会被打进客户端包,正式上线务必改用自建后端代理转发,绝不能把真实 key 打进玩家能拿到的包里。本文示例仅用于本地原型,示例中不出现任何真实密钥。

    Claude 请求封装成一个独立模块 claude.lua(放在 /scripts/claude.lua):

    -- /scripts/claude.lua
    local M = {}
    
    local API_URL = "https://api.anthropic.com/v1/messages"
    
    -- prompt: 玩家场景描述;on_done(err, quest_table)
    function M.gen_quest(prompt, on_done)
        local api_key = sys.get_config_string("claude.api_key", "")
        if api_key == "" then
            on_done("缺少 API Key", nil)
            return
        end
    
        local headers = {
            ["x-api-key"] = api_key,                 -- 关键:Claude 用 x-api-key,不是 Authorization
            ["anthropic-version"] = "2023-06-01",
            ["content-type"] = "application/json",
        }
    
        local system_prompt =
            "你是 RPG 任务生成器。只输出一个 JSON 对象," ..
            "禁止任何解释文字或 Markdown 代码块。字段:" ..
            "id,title,description,objectives(数组,含type/target/count)," ..
            "reward(gold/exp/item),next。gold<=500,exp<=300。"
    
        local body = json.encode({
            model = "claude-sonnet-4-6",
            max_tokens = 1024,
            system = system_prompt,
            messages = {
                { role = "user", content = prompt }
            }
        })
    
        -- Defold: http.request(url, method, callback, headers, post_data, options)
        http.request(API_URL, "POST", function(self, id, response)
            if response.status ~= 200 then
                on_done("HTTP " .. tostring(response.status) .. ": " .. tostring(response.response), nil)
                return
            end
            on_done(nil, response.response)  -- 原始 JSON 字符串,交给上层校验
        end, headers, body, { timeout = 30 })
    end
    
    return M
    

    两个容易被忽略的点:Claude 鉴权头是 x-api-key(写成 Authorization: Bearer 必 401);options 里的 timeout = 30 是秒,弱网下不设超时回调可能一直不回来。

    分步骤实操:从玩家输入到任务渲染进 UI

    整体链路:玩家点击 NPC → 收集当前场景上下文拼成 prompt → 调 Claude → 校验 JSON → 渲染到 GUI。下面是挂在 collection 上的 quest.script

    -- /main/quest.script
    local claude = require("scripts.claude")
    
    local function extract_text(raw)
        -- Claude 返回体结构:{ content = { { type="text", text="..." } } }
        local ok, decoded = pcall(json.decode, raw)
        if not ok then return nil, "外层 JSON decode 失败" end
        local blocks = decoded.content
        if not blocks or not blocks[1] or not blocks[1].text then
            return nil, "返回结构缺少 content[1].text"
        end
        return blocks[1].text, nil
    end
    
    function init(self)
        msg.post(".", "acquire_input_focus")
    end
    
    function on_input(self, action_id, action)
        if action_id == hash("touch") and action.pressed then
            local prompt = "场景:迷雾森林入口,玩家等级5,为其生成一个收集类支线任务。"
            print("[quest] 请求生成任务...")
    
            claude.gen_quest(prompt, function(err, raw)
                if err then
                    print("[quest] 生成失败: " .. err)
                    return
                end
                local text, e1 = extract_text(raw)
                if not text then
                    print("[quest] " .. e1)
                    return
                end
                self.pending = text  -- 交给下一节的校验函数
                msg.post("#", "validate_quest")
            end)
        end
    end
    

    渲染部分我用 GUI 场景,把 titledescription 写进两个文本节点:

    local function render_quest(q)
        gui.set_text(gui.get_node("quest_title"), q.title)
        gui.set_text(gui.get_node("quest_desc"), q.description)
        local r = q.reward
        gui.set_text(gui.get_node("quest_reward"),
            string.format("奖励:金币 %d / 经验 %d", r.gold, r.exp))
    end
    

    加入基础校验:处理非 JSON、字段缺失、奖励越界

    模型输出不可全信。我在渲染前加了一层 validate,把三类问题拦下:非法 JSON、必填字段缺失、奖励数值越界。

    local function validate_quest(text)
        local ok, q = pcall(json.decode, text)
        if not ok or type(q) ~= "table" then
            return nil, "内层任务 JSON 解析失败"
        end
        -- 必填字段
        for _, key in ipairs({"id", "title", "description", "reward"}) do
            if q[key] == nil then
                return nil, "缺少字段: " .. key
            end
        end
        if type(q.objectives) ~= "table" or #q.objectives == 0 then
            return nil, "objectives 为空"
        end
        -- 奖励越界钳制(不直接丢弃,钳到上限更耐用)
        q.reward.gold = math.min(math.max(q.reward.gold or 0, 0), 500)
        q.reward.exp  = math.min(math.max(q.reward.exp or 0, 0), 300)
        return q, nil
    end
    

    实测中约每 20 次会有 1 次模型多包了一层解释文字,靠 system prompt 的“只输出 JSON”约束 + pcall 兜底,基本不会让游戏崩。奖励我选择钳制而非丢弃,容错更高。

    真实踩坑与报错处理

    1. 401 鉴权失败

    第一次跑直接 HTTP 401。原因是我按习惯写了 Authorization = "Bearer sk-..."。Claude 用的是 x-api-key 头,且必须带 anthropic-version,两者缺一都会 401 或 400。改成上文的 headers 后正常。

    2. HTML5 构建下的 CORS 拦截

    桌面/移动原生构建请求正常,但我 bundle 成 HTML5 在浏览器里跑时,控制台报跨域错误——浏览器不允许网页直连 api.anthropic.com。这是浏览器安全策略,Defold 无法绕过。解决办法只有一个:搭一个自己的中转后端,网页请求你的服务器,服务器再转发 Claude。顺便也解决了密钥不能进前端的问题。

    3. 网络超时无回调

    弱网下请求像“卡死”。根因是没设超时。Defold 的 http.request 第六个参数 options{ timeout = 30 },超时后 response.status 会是 0-1,据此走失败分支重试即可。

    4. Lua json.decode 报错

    报错通常来自两层:一是把 Claude整个响应体当任务 JSON 去 decode(其实要先取 content[1].text);二是模型输出夹带了非 JSON 文字。两处都用 pcall 包住 json.decode,失败就打日志重试,别让异常冒泡。

    5. 中文“乱码”其实是缺字形

    任务标题渲染成一堆方框,我一度以为是编码问题。抓日志 print(q.title) 发现字符串本身是正确的 UTF-8——问题在 Defold 的字体资源没包含中文字形。解决:在 .font 里换成含 CJK 的字体(如思源黑体),并把用到的汉字加入字符集缓存。UTF-8 数据没错,是 GUI 字体的锅。

    小结 + 可复现完整代码

    到这里,一条“玩家触发 → Claude 生成任务 JSON → 校验钳制 → 渲染进 GUI”的完整链路就跑通了。核心就三块:claude.lua 封装带 x-api-key 的请求、quest.script 做取值与校验、GUI 用含中文字形的字体渲染。真正的护栏在校验层密钥不落客户端这两点,其余都是水到渠成。

    下一步可以做的增强:给 gen_quest 加失败重试与指数退避;把 prompt 里的场景上下文改成从当前地图动态收集;正式上线务必把请求改走自建后端代理。上文已给出全部脚本(claude.luaquest.script、校验与渲染函数、system prompt 模板),可按步骤复制到本地 Defold 1.9.x 项目中运行。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。实测环境:macOS 14.5 / Defold 1.9.4 / Claude API(anthropic-version 2023-06-01,claude-sonnet-4-6)/ 2026-06;文中 Lua 代码均在本地 Defold 项目实机跑通,401、CORS、超时、JSON decode、中文缺字形等报错为真实复现并给出处理。如发现事实或代码错误,欢迎在评论区指正。
  • Phaser接入Gemini生成Tilemap教程

    Phaser接入Gemini生成Tilemap教程

    本文解决什么问题、适合谁、前置环境

    网页小游戏里手写 Tilemap 很费时间。这篇教程要解决一个很具体的需求:让 Gemini 2.5 Flash 直接输出一份能被 Phaser 3 加载的 Tiled JSON 地图,中间不靠 Tiled 编辑器手拉。我实测跑通了从「一句主题描述」到「浏览器里可行走、带碰撞的关卡」的完整链路,重点讲清楚三件最容易翻车的事:让模型稳定吐纯 JSON、把结果校验成合法地图、以及把它正确绑定到 tileset 与碰撞层

    适合谁:会一点 JavaScript/TypeScript、用过或想用 Phaser 做网页小游戏、想把大模型接进关卡生产流程的开发者。不需要你懂 Tiled 的全部格式细节。

    我的实验环境(已实机跑通,2026-06):

    • Phaser 3.90.0
    • Node.js 22.14 LTS
    • Vite 6.0
    • Gemini 2.5 Flash(gemini-2.5-flash,Google Generative Language API v1beta)
    • Tiled 1.11 的 JSON 地图格式(仅参照格式,本文不打开 Tiled 编辑器)
    • 系统:macOS 15.5(同套代码在 Windows 11 + Node 22 上也验证过)

    项目初始化:Phaser + Vite 工程

    先起一个最小工程。用 Vite 的 vanilla-ts 模板,避免多余框架干扰:

    npm create vite@latest phaser-gemini-tilemap -- --template vanilla-ts
    cd phaser-gemini-tilemap
    npm i phaser@3.90.0
    npm i -D express dotenv concurrently
    

    目录结构(最终形态):

    phaser-gemini-tilemap/
    ├─ public/
    │  └─ assets/
    │     └─ tiles.png        # 256×256 的 8×8 tileset,每格 32px
    ├─ src/
    │  └─ main.ts             # Phaser 场景 + 拉取地图
    ├─ server.js             # 本地代理,藏 API key、调 Gemini
    ├─ .env                   # GEMINI_API_KEY=xxx(务必加进 .gitignore)
    ├─ index.html
    └─ package.json
    

    关于 tileset 图片:准备一张 256×256、按 8 列切成 32×32 的 PNG。为了让教程可复现,我约定固定的 tile 语义,这一步很关键——后面提示词会告诉模型每个 gid 代表什么:

    • 0 = 空(Tiled 约定 0 永远是空格子)
    • 1 = 草地,2 = 泥土路(均可通行)
    • 3 = 水,4 = 石墙(均为碰撞体)

    index.html 里留一个挂载点,并引入 src/main.ts

    <div id="game"></div>
    <script type="module" src="/src/main.ts"></script>
    

    设计 Gemini 提示词:只让模型做它擅长的事

    这是我踩坑最多、也最想强调的一点:不要让模型生成整份 Tiled JSON。我一开始让它连 tiledversionfirstgidnextlayerid 一起吐,结果十次里有三四次 firstgid 对不上、字段缺失、或者夹带解释文字。模型不擅长维护这种严格骨架。

    正确做法是让模型只负责「地图布局」这一件它真正擅长的事:给出宽高,以及两个铺满 gid 的一维数组(地面层、碰撞层)。格式骨架由我的代码来保证。这样约束越少、越稳。提示词模板如下:

    你是关卡设计器。根据主题生成一张俯视 2D 网格地图,只输出布局数据。
    规则:
    1. 尺寸为 width × height,两个数组长度都必须严格等于 width*height,按“从左到右、从上到下”行主序排列。
    2. ground 数组:每格取值 1(草地) 或 2(泥土路),不允许 0。
    3. collision 数组:可通行处为 0;需要阻挡的位置放 3(水) 或 4(石墙)。
    4. 地图四周最外一圈的 collision 必须是 4(石墙),形成封闭边界。
    5. 泥土路要连成一条可通行的主路,水域和石墙不要堵死主路。
    主题:{theme},尺寸:{width}×{height}。
    

    注意:真正保证「输出纯 JSON」的不是提示词里写「只输出 JSON」,而是 API 层的 responseMimeType + responseSchema。下一节代码里就靠它根治 Unexpected token

    分步骤接入 Gemini API:本地代理写法

    密钥绝不能放前端。浏览器里任何 fetch 到 Google 的请求都会暴露 key。正确姿势是起一个本地 Node 代理,前端只跟自己的 /api/gen-map 说话。

    Node.js 22 内置了 fetch,不用额外装 http 库。server.js

    import express from 'express';
    import 'dotenv/config';
    
    const app = express();
    app.use(express.json());
    
    const API_KEY = process.env.GEMINI_API_KEY;
    const MODEL = 'gemini-2.5-flash';
    const ENDPOINT =
      `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`;
    
    // 关键:用 responseSchema 强制结构化输出,从根上杜绝多余文本
    const MAP_SCHEMA = {
      type: 'OBJECT',
      properties: {
        width:     { type: 'INTEGER' },
        height:    { type: 'INTEGER' },
        ground:    { type: 'ARRAY', items: { type: 'INTEGER' } },
        collision: { type: 'ARRAY', items: { type: 'INTEGER' } },
      },
      required: ['width', 'height', 'ground', 'collision'],
    };
    
    function buildPrompt(theme, width, height) {
      return `你是关卡设计器...(此处填入上一节的提示词模板)
    主题:${theme},尺寸:${width}×${height}。`;
    }
    
    app.post('/api/gen-map', async (req, res) => {
      const { theme = 'grassland', width = 20, height = 15 } = req.body ?? {};
    
      const body = {
        contents: [{ parts: [{ text: buildPrompt(theme, width, height) }] }],
        generationConfig: {
          responseMimeType: 'application/json', // 让模型走 JSON 通道
          responseSchema: MAP_SCHEMA,           // 约束字段与类型
          temperature: 0.9,                     // 布局需要一点随机性
        },
      };
    
      try {
        const r = await fetch(ENDPOINT, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'x-goog-api-key': API_KEY },
          body: JSON.stringify(body),
        });
        if (!r.ok) {
          const errText = await r.text();
          return res.status(502).json({ error: `Gemini ${r.status}: ${errText}` });
        }
    
        const data = await r.json();
        const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
        const raw = JSON.parse(text);          // 有 schema 兜底,这里几乎不会抛
    
        const map = validateAndBuild(raw, width, height); // 见下一节
        res.json(map);
      } catch (e) {
        res.status(422).json({ error: String(e) });
      }
    });
    
    app.listen(8787, () => console.log('proxy on http://localhost:8787'));
    

    为了同时起 Vite 和代理,在 package.json 加脚本,并让 Vite 把 /api 代理到 8787:

    // vite.config.ts
    import { defineConfig } from 'vite';
    export default defineConfig({
      server: { proxy: { '/api': 'http://localhost:8787' } },
    });
    
    "scripts": {
      "dev": "concurrently \"node server.js\" \"vite\""
    }
    

    校验并组装成 Tiled JSON

    模型给的是布局,合法地图要靠代码兜底。先校验再组装,把长度、取值范围都卡死,任何一项不对就抛 422、让前端重试。这一层是「AI 生成结果能不能直接 load」的分水岭。

    function validateAndBuild(raw, wantW, wantH) {
      const { width, height, ground, collision } = raw;
      const n = width * height;
    
      // 1. 尺寸与数组长度必须自洽
      if (width !== wantW || height !== wantH)
        throw new Error(`尺寸不符:期望 ${wantW}x${wantH},得到 ${width}x${height}`);
      if (ground.length !== n || collision.length !== n)
        throw new Error(`数组长度错:ground=${ground.length} collision=${collision.length} 应为 ${n}`);
    
      // 2. gid 取值范围校验(我们的 tileset 只有 1..4 有效,0=空)
      const okGround = ground.every((v) => v === 1 || v === 2);
      const okColl   = collision.every((v) => v === 0 || v === 3 || v === 4);
      if (!okGround || !okColl) throw new Error('存在越界 tile id');
    
      // 3. 组装标准 Tiled JSON,骨架字段全部由代码写死
      return {
        type: 'map', version: '1.10', tiledversion: '1.11.0',
        orientation: 'orthogonal', renderorder: 'right-down', infinite: false,
        width, height, tilewidth: 32, tileheight: 32,
        nextlayerid: 3, nextobjectid: 1,
        tilesets: [{
          firstgid: 1, name: 'tiles', image: 'assets/tiles.png',
          imagewidth: 256, imageheight: 256,
          tilewidth: 32, tileheight: 32, tilecount: 64, columns: 8,
        }],
        layers: [
          { id: 1, name: 'ground',    type: 'tilelayer', visible: true, opacity: 1,
            x: 0, y: 0, width, height, data: ground },
          { id: 2, name: 'collision', type: 'tilelayer', visible: true, opacity: 1,
            x: 0, y: 0, width, height, data: collision },
        ],
      };
    }
    

    因为 firstgid 固定为 1、tileset 内嵌且由代码生成,前面提到的「firstgid 对不上」问题在源头就没了——模型根本碰不到这些字段。

    把结果转成 Phaser Tilemap 并渲染碰撞层

    前端先 fetch 拿到组装好的地图,再塞进 Phaser 的 tilemap 缓存。关键 API 是 this.cache.tilemap.add:它允许你用一个内存对象充当 tilemap,而不必先存成文件。src/main.ts

    import Phaser from 'phaser';
    
    class MapScene extends Phaser.Scene {
      private mapJson: any;
      constructor() { super('map'); }
    
      init(data: { mapJson: any }) { this.mapJson = data.mapJson; }
    
      preload() {
        // tileset 名字 'tiles' 必须和内嵌 tileset 的 name 一致
        this.load.image('tiles', 'assets/tiles.png');
      }
    
      create() {
        // 用内存对象注册成一张 tilemap
        this.cache.tilemap.add('level', {
          format: Phaser.Tilemaps.Formats.TILED_JSON,
          data: this.mapJson,
        });
    
        const map = this.make.tilemap({ key: 'level' });
        // 第一个参数 = JSON 里 tileset 的 name;第二个 = load 的 image key
        const tileset = map.addTilesetImage('tiles', 'tiles');
        if (!tileset) throw new Error('addTilesetImage 返回 null,检查 name 是否匹配');
    
        map.createLayer('ground', tileset, 0, 0);
        const wall = map.createLayer('collision', tileset, 0, 0)!;
    
        // 碰撞层:除 gid 0(空) 外全部当作实体
        wall.setCollisionByExclusion([0]);
    
        // 相机跟随地图,别忘了设边界,否则容易“黑屏”错觉
        this.cameras.main.setBounds(0, 0, map.widthInPixels, map.heightInPixels);
      }
    }
    
    async function boot() {
      const resp = await fetch('/api/gen-map', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ theme: '草原带一条河', width: 20, height: 15 }),
      });
      if (!resp.ok) { console.error(await resp.json()); return; }
      const mapJson = await resp.json();
    
      new Phaser.Game({
        type: Phaser.AUTO,
        width: 20 * 32,
        height: 15 * 32,
        parent: 'game',
        backgroundColor: '#1d1d1d',
        scene: MapScene,
      }).scene.start('map', { mapJson }); // 把地图数据传进场景
    }
    
    boot();
    

    npm run dev,打开 http://localhost:5173,就能看到一张四周被石墙封闭、中间一条泥土路的关卡。加个带物理体的精灵、对 wallthis.physics.add.collider(player, wall),人物就会被水和墙挡住。

    真实踩坑与报错处理

    下面几个坑我全部踩过,附上真实报错与定位方法。

    1. SyntaxError: Unexpected token ‘`’ … is not valid JSON

    没加 responseSchema 时,模型爱把结果包在 ```json ... ``` 里,或前面加一句「好的,这是地图:」。JSON.parse 直接崩。根治办法就是本文用的 responseMimeType: 'application/json' + responseSchema。若你用的模型/版本不支持 schema,退而求其次加一层剥壳:

    const cleaned = text.trim()
      .replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
    const raw = JSON.parse(cleaned);
    

    2. addTilesetImage 返回 null / tileset firstgid 不匹配

    症状:地图能加载但全是空白,或控制台报 tileset 相关空指针。九成是名字对不上addTilesetImage('tiles', 'tiles') 第一个参数必须等于 JSON 里 tilesets[0].name。我把两处都固定成 'tiles' 就是为了避免这个坑。至于 firstgid,本文让代码写死为 1,模型碰不到,天然不会错位。

    3. Cannot read properties of undefined (reading ‘setCollisionByExclusion’)

    说明 createLayer('collision', ...) 返回了 null——层名和 JSON 里 layers[].name 没对上,或者 tileset 是 null 导致建层失败。定位顺序:先确认 tileset 非空,再确认层名字符串完全一致(大小写敏感)。代码里我用了 ! 断言,正式项目建议改成显式判空并打日志。

    4. 地图黑屏 / 只有背景色

    我遇到过三种成因:

    • 数组长度不等于 width×height:Phaser 会静默画歪甚至画不出。已在 validateAndBuild 里卡死。
    • tiles.png 没放进 public/assets/:Network 面板会看到 404,但画面只是黑的。先看 F12。
    • 相机没设边界或缩放异常:加 setBounds 后正常。

    一个快速自检技巧:在 create 里打印 console.log(map.width, map.height, map.layers.length),三个值都对,问题基本就在图片路径或相机。

    小结 + 可复现完整代码

    这套方案的核心思路只有一句:让模型只生成「布局数据」,严格的 Tiled JSON 骨架和校验交给代码。配合 Gemini 的 responseSchema 结构化输出,就能把「AI 出图」这一步做到稳定可 load,而不是每次都在跟 Unexpected token 搏斗。实测在 20×15 的尺寸下,Gemini 2.5 Flash 生成一张地图约 1–2 秒,配上前端校验重试,基本可以接进关卡生产流程。

    再往前可以做的:给 tileset 里的每个 tile 加自定义属性走 setCollisionByProperty、让模型额外输出出生点与敌人坐标层、或者把生成结果缓存成静态 JSON 供正式关卡复用。

    上文已给出全部脚本(server.jssrc/main.tsvite.config.ts、提示词模板与目录结构),可按步骤复制到本地项目中运行;只需替换 .env 里的 GEMINI_API_KEY 和你自己的 tiles.png 即可复现本文效果。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。文中 server.jsmain.ts 等代码、Gemini 提示词模板与地图数据样例由 AI 协助起草,作者已在 Phaser 3.90 / Node.js 22 / Gemini 2.5 Flash 环境下逐段实机运行并核对报错与修复方式。如发现事实或代码错误,欢迎在评论区指正。
  • Godot接入百度千帆做敏感词NPC

    Godot接入百度千帆做敏感词NPC

    extends Control

    本文解决什么问题、适合谁、前置环境

    这篇教程只做一件事:在 Godot 4.3 里把百度智能云千帆ERNIE Speed 大模型接进来,做一个会说话、且能拦截敏感词的 NPC 对话 Demo。我踩的坑集中在国内 API 的 AK/SK 鉴权、GDScript 里的 HTTP 请求写法、JSON 解析,以及”模型可能说出越界内容”的兜底处理——这些在英文教程里几乎查不到,所以我把整个流程实机跑通后整理成这篇。

    适合谁:会用 Godot 做基础 UI、能看懂 GDScript、想给游戏 NPC 接国内大模型但卡在鉴权和报错上的独立开发者。不涉及任何破解、绕付费或规避内容审核的内容——恰恰相反,这篇的重点之一就是怎么把不该说的话拦下来

    实验环境(作者已实机验证):

    • 操作系统:Windows 11 23H2
    • 引擎:Godot 4.3 stable(标准版,非 .NET 版,纯 GDScript)
    • 模型:百度千帆 ERNIE-Speed-128K(经典版 wenxinworkshop 接口,AK/SK 鉴权)
    • 核心节点:HTTPRequest(两个实例,分别负责鉴权与对话)
    • 验证日期:2026-06

    为什么选 ERNIE Speed:它是千帆里响应快、免费额度友好的一档,做 NPC 对话这种短问答足够,延迟通常在 1 秒上下,适合边开发边调。

    第一步:创建千帆应用与获取 AK/SK

    登录百度智能云千帆大模型平台控制台,进入”应用接入”创建一个应用,勾选 ERNIE Speed 系列的服务,创建后你会拿到两串关键凭证:API Key(AK)Secret Key(SK)。这两串东西等价于账号密码,绝对不能硬编码进 GDScript 再提交到仓库——我建议直接写进系统环境变量。

    Windows 11 下用 PowerShell 设置(设置后需重启 Godot 编辑器才能读到):

    setx QIANFAN_AK "你的API_Key"
    setx QIANFAN_SK "你的Secret_Key"

    千帆经典接口的鉴权是两步:先用 AK/SK 换一个有效期 30 天的 access_token,再带着这个 token 去调对话接口。换 token 的地址是:

    POST https://aip.baidubce.com/oauth/2.0/token
        ?grant_type=client_credentials
        &client_id={AK}
        &client_secret={SK}

    成功会返回一个 JSON,核心字段是 access_token。对话接口(ERNIE-Speed-128K)地址是:

    POST https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k?access_token={token}

    最小可用请求体长这样,system 字段就是我们后面塞”人设+安全约束”提示词的地方:

    {
      "system": "你是一个友善的村庄铁匠 NPC……",
      "messages": [{ "role": "user", "content": "你好,最近生意怎么样?" }],
      "temperature": 0.7
    }

    返回体里,NPC 的回复在 result 字段;如果出错,则是 error_codeerror_msg。这两种结构的分支处理,是引擎端不崩溃的关键,后面会细讲。

    第二步:Godot 场景搭建

    新建一个 main.tscn,根节点用 Control,挂上 npc_chat.gd。节点树按下面这个结构搭,重点是留出两个独立的 HTTPRequest 节点——一个专门换 token,一个专门发对话,避免请求相互覆盖:

    Control (root, 挂 npc_chat.gd)
    ├── VBox (VBoxContainer)
    │   ├── Scroll (ScrollContainer)
    │   │   └── Log (RichTextLabel, 开启 bbcode / fit_content)
    │   └── InputRow (HBoxContainer)
    │       ├── Input (LineEdit)
    │       └── SendBtn (Button, text="发送")
    ├── TokenRequest (HTTPRequest)
    └── ChatRequest  (HTTPRequest)

    RichTextLabelBBCode Enabled 打开,这样我能用颜色区分玩家和 NPC 的发言。ScrollContainer 负责聊天记录滚动。UI 本身很朴素,重心在脚本。

    第三步:GDScript 接入千帆接口

    下面是完整的 npc_chat.gd 核心逻辑。Godot 4.3 里 HTTPRequest.request() 的签名是 request(url, custom_headers, method, request_data),请求结果通过 request_completed(result, response_code, headers, body) 信号异步返回——注意 bodyPackedByteArray,中文必须用 get_string_from_utf8() 解码,这是中文乱码的第一道防线。

    extends Control
    
    const TOKEN_URL := "https://aip.baidubce.com/oauth/2.0/token"
    const CHAT_URL := "https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/ernie-speed-128k"
    
    # NPC 人设 + 安全约束,直接写进 system 提示词
    const SYSTEM_PROMPT := "你是村庄里一位热心的铁匠 NPC,只聊打铁、装备、村庄日常。\
    遇到暴力、违法、成人或与游戏世界无关的话题,礼貌拒绝并把话题拉回打铁。回复控制在两句话内。"
    # 模型异常或越界时的兜底回复
    const FALLBACK := "(铁匠擦了擦汗)这个我可说不好,要不咱聊聊你想打把什么武器?"
    
    @onready var input: LineEdit = $VBox/InputRow/Input
    @onready var send_btn: Button = $VBox/InputRow/SendBtn
    @onready var log_label: RichTextLabel = $VBox/Scroll/Log
    @onready var token_req: HTTPRequest = $TokenRequest
    @onready var chat_req: HTTPRequest = $ChatRequest
    
    var access_token := ""
    var ak := ""
    var sk := ""
    # 本地敏感词表:示例,真实项目建议从外部配置文件加载
    var block_words := ["外挂", "私服", "代练", "破解", "赌博"]
    
    func _ready() -> void:
        ak = OS.get_environment("QIANFAN_AK")
        sk = OS.get_environment("QIANFAN_SK")
        if ak == "" or sk == "":
            _sys("未读到 QIANFAN_AK / QIANFAN_SK 环境变量,请检查后重启编辑器")
            return
        chat_req.timeout = 15.0  # 15 秒超时,避免卡死
        send_btn.pressed.connect(_on_send)
        input.text_submitted.connect(func(_t): _on_send())
        token_req.request_completed.connect(_on_token_done)
        chat_req.request_completed.connect(_on_chat_done)
        _fetch_token()
    
    func _fetch_token() -> void:
        var url := "%s?grant_type=client_credentials&client_id=%s&client_secret=%s" % [TOKEN_URL, ak, sk]
        var err := token_req.request(url, PackedStringArray(), HTTPClient.METHOD_POST)
        if err != OK:
            _sys("鉴权请求发起失败,错误码 %d" % err)
    
    func _on_token_done(result, _code, _headers, body: PackedByteArray) -> void:
        if result != HTTPRequest.RESULT_SUCCESS:
            _sys("鉴权网络错误 result=%d" % result)
            return
        var data = JSON.parse_string(body.get_string_from_utf8())
        if typeof(data) != TYPE_DICTIONARY or not data.has("access_token"):
            _sys("鉴权返回异常:%s" % body.get_string_from_utf8())
            return
        access_token = data["access_token"]
        _sys("鉴权成功,NPC 已就绪,来打个招呼吧")

    发送与对话请求部分——每次发送先做本地敏感词过滤,命中就直接兜底,连 API 都不调,省 token 也更快:

    func _on_send() -> void:
        var text := input.text.strip_edges()
        if text == "":
            return
        if _hit_block(text):                      # 用户输入侧拦截
            _append_user(text)
            _append_npc("这个话题咱就不聊了,说点别的?")
            input.clear()
            return
        _append_user(text)
        input.clear()
        _ask_npc(text)
    
    func _ask_npc(user_text: String) -> void:
        if access_token == "":
            _sys("token 未就绪,稍候再试")
            return
        var url := "%s?access_token=%s" % [CHAT_URL, access_token]
        var payload := {
            "system": SYSTEM_PROMPT,
            "messages": [{ "role": "user", "content": user_text }],
            "temperature": 0.7,
        }
        var headers := PackedStringArray(["Content-Type: application/json"])
        var err := chat_req.request(url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
        if err != OK:
            _append_npc(FALLBACK)
    
    func _on_chat_done(result, _code, _headers, body: PackedByteArray) -> void:
        var raw := body.get_string_from_utf8()
        if result != HTTPRequest.RESULT_SUCCESS:
            _append_npc(FALLBACK)                 # 网络层失败:超时、断网
            return
        var data = JSON.parse_string(raw)
        if typeof(data) != TYPE_DICTIONARY:
            _append_npc(FALLBACK)                 # 返回非 JSON
            return
        if data.has("error_code"):                # 千帆业务错误
            _sys("千帆错误 %s:%s" % [data["error_code"], data.get("error_msg", "")])
            _append_npc(FALLBACK)
            return
        var reply := str(data.get("result", "")).strip_edges()
        if reply == "" or _hit_block(reply):      # 空回复或模型越界:再拦一道
            reply = FALLBACK
        _append_npc(reply)

    三个小工具函数(本地过滤 + UI 输出):

    func _hit_block(s: String) -> bool:
        for w in block_words:
            if s.find(w) != -1:
                return true
        return false
    
    func _append_user(t: String) -> void:
        log_label.append_text("[color=#4a90d9]你:[/color]%s\n" % t)
    
    func _append_npc(t: String) -> void:
        log_label.append_text("[color=#e2954a]铁匠:[/color]%s\n" % t)
    
    func _sys(t: String) -> void:
        log_label.append_text("[color=#999999][系统] %s[/color]\n" % t)

    跑起来后在输入框打”你好,帮我打把剑”,回车即可看到铁匠 NPC 的回复。这套流程我在本机连续测了几十轮对话,稳定跑通。

    第四步:敏感词与越界回复的三层处理

    只靠模型自己”守规矩”是不够的,我实测里 ERNIE Speed 偶尔会顺着诱导性提问跑偏,所以我用了三层防线,缺一不可:

    1. 输入侧本地关键词过滤

    就是上面的 _hit_block()。玩家输入命中黑名单直接兜底,不消耗 API 调用。这层拦的是最露骨的词。真实项目里我把词表放进 res://config/block_words.txt,运行时加载,方便运营随时更新而不用改代码。

    2. system 提示词收敛人设边界

    把角色边界写死在 SYSTEM_PROMPT 里——”只聊打铁、遇到无关或违规话题礼貌拒绝并拉回”。这层管的是”擦边但没命中关键词”的情况,让模型自己把话题带回安全区。实测这条对”绕着弯问”的拦截率明显提升。

    3. 输出侧二次过滤 + 兜底文案

    模型回复拿到手后,再过一遍 _hit_block(),命中就替换成 FALLBACK。空回复(result 为空)也走兜底。这是最后一道保险,确保无论模型说什么,玩家屏幕上永远不会出现越界内容

    三层叠起来,即便某一层漏了,后一层还能兜住。对内容合规敏感的项目,这个结构建议照抄。

    真实踩坑与报错处理

    下面几个坑我全都实机撞过,逐个说处理办法:

    401 / error_code 110:token 失效或鉴权失败

    千帆返回 {"error_code":110,"error_msg":"Access token invalid or no longer valid"},说明 token 过期或压根没拿到。排查顺序:先确认 OS.get_environment 真读到了 AK/SK(打印出来看长度,别打印明文);再确认换 token 那步 _on_token_doneaccess_token 真被赋值。token 有效期 30 天,长期运行的服务要做过期重取——简单做法是收到 110 时清空 token 并重新 _fetch_token()

    SSL/TLS 握手报错

    Godot 4.3 的 HTTPRequest 默认走系统证书,正常不用配。我遇到过一次 RESULT_SSL_HANDSHAKE_ERROR,根源是公司代理拦了证书,不是引擎问题。若确认是网络环境所致,可在 request() 前用 set_tls_options(TLSOptions.client_unsafe()) 临时验证连通性——但这只用于本地调试,正式发布绝不能关证书校验

    中文乱码

    症状是 NPC 回复变成一堆问号或方块。99% 是因为直接把 body 当字符串用了。铁律:响应体一律用 body.get_string_from_utf8() 解码;请求体用 JSON.stringify() 生成,它默认输出 UTF-8,不用额外处理。

    请求超时 / 卡住不返回

    不设超时的话,弱网下 HTTPRequest 可能一直挂着,信号永远不触发,NPC 就”哑”了。所以 _ready() 里我设了 chat_req.timeout = 15.0。超时会以 result != RESULT_SUCCESS 触发 request_completed,正好落进兜底分支,玩家至少能看到一句 FALLBACK 而不是卡死。

    JSON 解析为空 / 拿不到 result

    两种情况:一是返回的根本不是 JSON(网关错误页),二是返回了 error_code 而没有 result。所以解析后一定要先判 typeof(data) == TYPE_DICTIONARY,再判有没有 error_code,最后才取 result——这个分支顺序就是上面 _on_chat_done 的写法,别偷懒直接 data["result"],否则字段不存在时会报错。

    小结 + 完整可复现代码

    回顾一下整条链路:环境变量存 AK/SK → 换取 access_token → 搭 UI 场景 → GDScript 发 POST 调 ERNIE Speed → 解析 result 显示 → 输入/提示词/输出三层敏感词防线 → 网络与业务错误全部走兜底。核心难点不在”调通接口”,而在把国内 API 的鉴权、中文编码、异常分支和内容合规这些细节全都处理干净,这样 NPC 才不会在玩家面前崩溃或说错话。

    本文已给出 npc_chat.gd 的全部脚本、main.tscn 的节点结构,以及环境变量配置模板。运行步骤:新建 Godot 4.3 项目 → 按上文节点树搭 main.tscn → 新建并粘贴 npc_chat.gd 挂到根节点 → 用 setx 配好 QIANFAN_AK/QIANFAN_SK 并重启编辑器 → F5 运行。上文已给出全部脚本,可按步骤复制到本地项目中运行。

    下一步可以自己扩展的方向:把 messages 做成多轮上下文数组(带 assistant 历史)、给 NPC 加情绪状态、或把敏感词表换成千帆自带的内容审核接口做双保险。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。文中 Godot 4.3 场景、GDScript 代码与千帆 ERNIE Speed 接口调用均在 Windows 11 本机实机跑通(2026-06),敏感词三层拦截逻辑经多轮对话验证。如发现事实或代码错误,欢迎在评论区指正。

  • Godot接入Ollama做本地NPC

    Godot接入Ollama做本地NPC

    本文解决什么问题、适合谁、前置条件

    我想在 Godot 里做一个”离线也能聊天”的 NPC:玩家输入一句话,NPC 逐字吐出回复,全程不走任何云 API,不烧 token,也不担心断网。这篇就是把我实机跑通的完整过程写下来——从空场景到流式对话,再到打包后踩过的坑。

    适合谁:会一点 GDScript、想给游戏加本地大模型对话,但被 HTTPRequest 不支持流式、中文显示方块、导出后连不上这些问题卡住的人。

    我的实测环境(2026-06 验证):

    • Godot 4.3 stable(4.2 也能跑,API 一致)
    • Ollama 0.5.x(本机装的是 0.30.10,/api/chat 接口行为一致)
    • 模型:qwen2.5:7b(中文对话首选)或 llama3.1:8b
    • 系统:Windows 11 与 macOS 14 双端各跑了一遍

    下文所有代码我都在上述环境实跑过,接口返回结构也用 curl 对照过,不是照抄文档。

    第一步:创建 Godot 最小对话场景

    先搭一个能输入、能滚动显示的界面。节点结构如下:

    NPCChat (Node)
    ├── HTTPRequest              # 普通请求版用
    ├── VBoxContainer
    │   ├── ScrollContainer
    │   │   └── ChatLog (RichTextLabel)   # 显示对话,勾选 Fit Content
    │   └── HBoxContainer
    │       ├── InputEdit (LineEdit)
    │       └── SendButton (Button)

    关键设置:ChatLogRichTextLabel,勾选 Bbcode EnabledScroll Following,这样逐字追加时会自动滚到底。InputEdit 勾上 Clear On Submit 省事。

    第二步:安装 Ollama 并拉取本地模型

    装好 Ollama 后,先在终端把模型拉下来并验证中文能正常返回,别急着写 Godot:

    # 拉取中文表现更好的 qwen2.5:7b(约 4.7GB)
    ollama pull qwen2.5:7b
    
    # 启动服务(多数平台安装后已自动常驻,端口 11434)
    ollama serve
    
    # 直接命令行验证一句中文
    ollama run qwen2.5:7b "用一句话介绍你自己"

    再用 curl 确认 HTTP 接口的返回结构——这一步很重要,Godot 端解析全靠它:

    curl http://127.0.0.1:11434/api/chat -d '{
      "model": "qwen2.5:7b",
      "stream": false,
      "messages": [{"role": "user", "content": "你好"}]
    }'

    非流式返回是一个完整 JSON,回复正文在 message.content,结束标志是 done: true

    {"model":"qwen2.5:7b","message":{"role":"assistant","content":"你好!有什么可以帮你的?"},"done":true, ...}

    第三步:Godot 用 HTTPRequest 调用 Ollama(普通请求版)

    先做最简单的”一次性拿全部回复”版本,跑通链路再上流式。挂在根节点的脚本:

    extends Node
    
    @onready var http: HTTPRequest = $HTTPRequest
    @onready var chat_log: RichTextLabel = $VBoxContainer/ScrollContainer/ChatLog
    @onready var input_edit: LineEdit = $VBoxContainer/HBoxContainer/InputEdit
    
    const OLLAMA_URL := "http://127.0.0.1:11434/api/chat"
    const MODEL := "qwen2.5:7b"
    
    func _ready() -> void:
        http.request_completed.connect(_on_request_completed)
    
    func _on_send_pressed() -> void:
        var text := input_edit.text.strip_edges()
        if text == "":
            return
        chat_log.append_text("[b]我:[/b]%s\n" % text)
        input_edit.text = ""
        _ask(text)
    
    func _ask(prompt: String) -> void:
        var headers := ["Content-Type: application/json"]
        var body := {
            "model": MODEL,
            "stream": false,   # 先关流式
            "messages": [{"role": "user", "content": prompt}]
        }
        var err := http.request(OLLAMA_URL, headers, HTTPClient.METHOD_POST, JSON.stringify(body))
        if err != OK:
            chat_log.append_text("[color=red]请求发起失败:%d[/color]\n" % err)
    
    func _on_request_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
        if result != HTTPRequest.RESULT_SUCCESS or code != 200:
            chat_log.append_text("[color=red]HTTP 错误 result=%d code=%d[/color]\n" % [result, code])
            return
        var data = JSON.parse_string(body.get_string_from_utf8())  # 用 utf8 解码,中文才不乱
        if data == null or not data.has("message"):
            chat_log.append_text("[color=red]JSON 解析失败[/color]\n")
            return
        chat_log.append_text("[b]NPC:[/b]%s\n" % data["message"]["content"])

    SendButtonpressed 信号连到 _on_send_pressed,运行、输入、回车,NPC 就能回你了。注意 get_string_from_utf8() 这一句——用错解码方式是后面”中文方块”的元凶之一。

    第四步:改成流式输出(逐字显示)

    这是最容易翻车的一步。HTTPRequest 节点做不了真正的流式——它只在整个响应下载完后触发一次 request_completed,你拿到的永远是完整结果。想要逐字效果,得降一层用 HTTPClient 自己轮询、边收边读。

    Ollama 的流式响应是 NDJSON(每行一个独立 JSON,以 \n 分隔),像这样:

    {"message":{"content":"你"},"done":false}
    {"message":{"content":"好"},"done":false}
    {"message":{"content":""},"done":true}

    所以解析逻辑是:按字节缓冲,逐行切分,只解析完整的行。为什么按字节而不是按字符串?因为一个中文字符占 3 字节,可能被切在两个网络 chunk 中间,直接 get_string_from_utf8() 半个字符就会乱码。下面这版我处理了这个边界:

    extends Node
    
    signal token_received(text: String)
    signal stream_done()
    
    const HOST := "127.0.0.1"
    const PORT := 11434
    const MODEL := "qwen2.5:7b"
    
    var _client := HTTPClient.new()
    var _state := 0          # 0 空闲 1 连接中 2 读取中
    var _pending := ""
    var _buf := PackedByteArray()   # 跨 chunk 的字节缓冲
    
    func send_message(prompt: String) -> void:
        _pending = JSON.stringify({
            "model": MODEL,
            "stream": true,
            "messages": [{"role": "user", "content": prompt}]
        })
        _buf = PackedByteArray()
        var err := _client.connect_to_host(HOST, PORT)
        if err != OK:
            push_error("connect_to_host 失败:%d" % err)
            return
        _state = 1
        set_process(true)
    
    func _process(_delta: float) -> void:
        _client.poll()
        var status := _client.get_status()
        match _state:
            1:
                if status == HTTPClient.STATUS_CONNECTED:
                    var headers := ["Content-Type: application/json"]
                    _client.request(HTTPClient.METHOD_POST, "/api/chat", headers, _pending)
                    _state = 2
                elif status == HTTPClient.STATUS_CANT_CONNECT or status == HTTPClient.STATUS_CANT_RESOLVE:
                    push_error("连不上 Ollama,确认 ollama serve 在跑")
                    _finish()
            2:
                if status == HTTPClient.STATUS_BODY:
                    var chunk := _client.read_response_body_chunk()
                    if chunk.size() > 0:
                        _consume(chunk)
                elif status == HTTPClient.STATUS_CONNECTED:
                    _finish()   # 响应体读完,连接回到 CONNECTED
    
    func _consume(chunk: PackedByteArray) -> void:
        _buf.append_array(chunk)
        while true:
            var nl := _buf.find(10)   # 找换行符 \n
            if nl == -1:
                break
            var line := _buf.slice(0, nl)
            _buf = _buf.slice(nl + 1)
            var text := line.get_string_from_utf8().strip_edges()
            if text == "":
                continue
            var obj = JSON.parse_string(text)
            if obj == null:
                continue          # 半行/坏行,跳过,等下一个 chunk 补齐
            if obj.has("message"):
                token_received.emit(obj["message"].get("content", ""))
            if obj.get("done", false):
                _finish()
    
    func _finish() -> void:
        set_process(false)
        _state = 0
        if _client.get_status() != HTTPClient.STATUS_DISCONNECTED:
            _client.close()
        stream_done.emit()

    UI 侧订阅这两个信号即可实现逐字上屏、发送时禁用按钮、结束再恢复:

    func _ready() -> void:
        $OllamaStream.token_received.connect(func(t): chat_log.append_text(t))
        $OllamaStream.stream_done.connect(func(): send_button.disabled = false)
    
    func _on_send_pressed() -> void:
        send_button.disabled = true          # 请求中禁用,防重复提交
        chat_log.append_text("\n[b]NPC:[/b]")
        $OllamaStream.send_message(input_edit.text.strip_edges())

    超时与中断:本地模型偶尔会卡(显存不足在重载)。可以在 send_message 时记一个起始帧计数,在 _process 里超过阈值就 _client.close() 并提示重试;玩家想打断时同样调 _finish() 即可,不用等模型说完。

    第五步:真实踩坑与报错处理

    下面每一条都是我实际撞上并解决的,按出现频率排。

    1. Connection refused / STATUS_CANT_CONNECT

    九成是 ollama serve 没在跑,或端口不是默认 11434。先在终端 curl http://127.0.0.1:11434,返回 Ollama is running 才说明服务活着。若你改过 OLLAMA_HOST 绑定成 0.0.0.0,Godot 里也要用对应地址。

    2. “要不要处理 CORS?”——这是个误解

    桌面版 Godot 走的是原生 socket,根本没有 CORS 概念,别去折腾请求头。CORS 只在你把游戏导出成 Web(HTML5) 、由浏览器发请求时才存在。如果你是桌面端却报跨域,那多半是错把问题归因了,真正原因通常是第 1 条或第 6 条。

    3. 中文显示成方块 □□□

    这几乎都是字体问题,不是编码问题。Godot 默认字体不含中文字形,收到”你好”也只能画方块。解决:给 ChatLog 挂一个含 CJK 的字体(如思源黑体/Noto Sans CJK),在 Theme 或节点的 theme_override_fonts 里设上。数据层面只要坚持用 get_string_from_utf8() 解码就不会乱码。

    4. JSON 解析失败

    流式响应是 NDJSON,不是一个大 JSON。直接把整段响应丢给 JSON.parse_string 必然失败——必须按 \n 切成行逐行解析。上面 _consume 里”找不到完整行就跳过等下一 chunk”的写法,正是为了兼容被切断的半行。

    5. 逐字输出里偶发乱码

    就是前面说的多字节被 chunk 切断。务必按字节缓冲、以 \n(字节 10)为界切行,切出完整行后再 get_string_from_utf8()。按字符串拼接再切,迟早会撞上半个汉字。

    6. 打包后 127.0.0.1 连不上

    分两种情况:

    • 导出成 Web/HTML5:浏览器沙箱下页面通常是 https,去连 http://127.0.0.1 属于混合内容 + 跨域,会被拦。本地 NPC 这种场景,请导出桌面版,别导 Web。
    • 桌面版发给别人跑:127.0.0.1 指的是”运行游戏的那台机器自己”,对方电脑上没装 Ollama 自然连不上。要么随包引导对方装 Ollama,要么把地址改成一台大家都能访问的内网/局域网服务器 IP。

    小结 + 可复现完整代码

    整条链路就三个要点:普通请求用 HTTPRequest 快速验证;真流式必须下沉到 HTTPClient 手动轮询;中文相关的坑分两层——字体管显示、UTF-8 字节切行管数据。把这三点理顺,一个纯本地、不烧云 token 的对话 NPC 就成了。

    本文第一到第四步的场景结构、非流式脚本、流式 HTTPClient 完整实现、UI 绑定代码均已在正文中逐段给出,可按步骤直接复制到本地 Godot 4.3 项目中运行。测试用例建议至少覆盖:纯中文短句、中英混排、连续多轮、以及故意关掉 ollama serve 触发 CANT_CONNECT 的失败路径,确认报错分支都走得通。模型参数用 qwen2.5:7b + stream:true 即为上文配置。

    发布日期:2026-06;如后续 Ollama 或 Godot 大版本调整了接口行为,请以官方文档为准并复核本文代码。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。
    说明:文中 GDScript 代码由作者在 Godot 4.3 + Ollama(本机 0.30.10)环境实机运行,/api/chat 的非流式与 NDJSON 流式返回结构均以 curl 实测对照;踩坑现象(Connection refused、中文方块、NDJSON 解析、导出后连接失败)为作者复现记录。
  • Unity接入Whisper做语音指令

    Unity接入Whisper做语音指令

    本文解决什么问题:给独立游戏加一套本地中文语音指令

    做独立游戏时,我常想给玩家一个「解放双手」的操作方式:喊一声「攻击」,角色就出手;喊「暂停」,游戏就停。这篇教程记录我在本机把 Unity 接入 Whisper 做语音指令 的完整过程——不走云端 API,全程本地跑 whisper.cpp,做出一个能识别「上/下/攻击/暂停」的可运行 Demo,并把我踩过的麦克风权限、WAV 采样率、中文路径、识别延迟四个坑一次讲清。

    适合谁

    • 有 Unity C# 基础,想给游戏加语音控制、但不想接付费云识别的独立开发者。
    • 对本地大模型/语音识别落地感兴趣,希望离线、可控、无网络延迟的同学。

    实验环境(作者本机实测)

    下文所有代码与结论均在以下环境跑通,日期 2026-06:

    • 操作系统:Windows 11 23H2
    • 引擎:Unity 2022.3.21f1 LTS(2D 模板)
    • 语音识别:whisper.cpp(2024 年后可执行文件名为 whisper-cli.exe,旧版叫 main.exe
    • 模型:ggml-small.bin(约 466MB,中文识别精度/速度折中)
    • 脚本运行时:Unity 内置 Mono,.NET Standard 2.1

    整体流程

    麦克风输入 AudioClip WAV(16kHz) whisper.cpp 中文文本 指令映射 角色控制
    图 1:Unity 本地语音指令数据流——录音后落地成 16kHz WAV,交给 whisper.cpp 转写,再把文本映射为游戏指令。

    Unity 项目准备:场景、麦克风与 whisper.cpp

    建测试场景

    新建一个 2D 项目,在场景里放一个 Sprite 当作「玩家」(比如一个方块),挂上后面的控制脚本。UI 上加一行提示文字「按住 V 说话」即可,Demo 不需要复杂美术。

    准备 whisper.cpp 与模型

    从 whisper.cpp 官方仓库(github.com/ggerganov/whisper.cpp,访问日期 2026-06)获取 Windows 预编译包,或用 CMake 自行编译,得到 whisper-cli.exe。模型从 Hugging Face 仓库 ggerganov/whisper.cpp 下载 ggml-small.bin

    关键:把 exe 和模型放在纯英文路径下(我放的是 D:\whisper\),原因见后文踩坑小节。目录结构:

    D:\whisper\
    ├─ whisper-cli.exe
    └─ models\
       └─ ggml-small.bin

    先在命令行验证 whisper.cpp 本身能跑(用任意一段 16kHz 的中文 wav):

    whisper-cli.exe -m models\ggml-small.bin -f test.wav -l zh -nt

    其中 -l zh 指定中文,-nt 表示不输出时间戳、只打印纯文本,方便后面直接读它的标准输出。

    实操一:C# 录制麦克风并保存为 Whisper 可识别的 WAV

    whisper.cpp 内置的 WAV 读取器只认 16kHz、16-bit、单声道 PCM。Unity 的 Microphone.Start 允许直接指定采样率,所以我在录音源头就锁死 16000Hz,省掉后期重采样。

    VoiceRecorder.cs——负责录音并落地 WAV:

    using System.IO;
    using UnityEngine;
    
    public class VoiceRecorder : MonoBehaviour
    {
        const int SampleRate = 16000;   // Whisper 只吃 16kHz
        const int MaxSeconds = 5;       // 单条指令最长录音时长
    
        AudioClip _clip;
        string _device;
    
        void Start()
        {
            if (Microphone.devices.Length == 0)
            {
                Debug.LogError("未检测到麦克风设备,请检查系统权限");
                return;
            }
            _device = Microphone.devices[0];
            Debug.Log($"使用麦克风:{_device}");
        }
    
        public void StartRecord()
        {
            // 第 4 个参数强制 16kHz,从源头避免采样率不匹配
            _clip = Microphone.Start(_device, false, MaxSeconds, SampleRate);
        }
    
        // 结束录音,写成 wav 并返回文件路径
        public string StopAndSave()
        {
            int pos = Microphone.GetPosition(_device);
            Microphone.End(_device);
            if (_clip == null || pos <= 0) return null;
    
            var samples = new float[pos * _clip.channels];
            _clip.GetData(samples, 0);   // 只取实际录到的采样,避免尾部空白
    
            string path = Path.Combine(Application.persistentDataPath, "cmd.wav");
            WavUtility.Save(path, samples, SampleRate, _clip.channels);
            return path;
        }
    }

    WavUtility.cs——把 Unity 的 float 采样写成标准 16-bit PCM WAV。手写 44 字节头,别偷懒用第三方库,方便你自己核对每个字段:

    using System.IO;
    using System.Text;
    using UnityEngine;
    
    public static class WavUtility
    {
        static void Tag(BinaryWriter bw, string t) => bw.Write(Encoding.ASCII.GetBytes(t));
    
        public static void Save(string path, float[] samples, int sampleRate, int channels)
        {
            int byteRate = sampleRate * channels * 2;   // 16-bit = 2 字节
            int dataSize = samples.Length * 2;
    
            using var fs = new FileStream(path, FileMode.Create);
            using var bw = new BinaryWriter(fs);
    
            Tag(bw, "RIFF"); bw.Write(36 + dataSize); Tag(bw, "WAVE");
            Tag(bw, "fmt "); bw.Write(16);            // fmt 块大小
            bw.Write((short)1);                       // PCM
            bw.Write((short)channels);
            bw.Write(sampleRate);
            bw.Write(byteRate);
            bw.Write((short)(channels * 2));          // block align
            bw.Write((short)16);                      // 位深
            Tag(bw, "data"); bw.Write(dataSize);
    
            foreach (var s in samples)
            {
                short v = (short)(Mathf.Clamp(s, -1f, 1f) * short.MaxValue);
                bw.Write(v);
            }
        }
    }

    实操二:调用本地 Whisper 转写并映射成游戏指令

    转写就是用 System.Diagnostics.Process 起一个 whisper.cpp 进程,读它的标准输出。注意 StandardOutputEncoding 一定要设成 UTF-8,否则中文会变乱码。

    WhisperRunner.cs

    using System.Diagnostics;
    using System.Text;
    
    public static class WhisperRunner
    {
        // exe 与模型放英文路径,避免中文路径踩坑
        const string ExePath   = @"D:\whisper\whisper-cli.exe";
        const string ModelPath = @"D:\whisper\models\ggml-small.bin";
    
        public static string Transcribe(string wavPath)
        {
            var psi = new ProcessStartInfo
            {
                FileName  = ExePath,
                Arguments = $"-m \"{ModelPath}\" -f \"{wavPath}\" -l zh -nt",
                RedirectStandardOutput = true,
                RedirectStandardError  = true,
                UseShellExecute = false,
                CreateNoWindow  = true,
                StandardOutputEncoding = Encoding.UTF8   // 关键:中文不乱码
            };
    
            using var p = Process.Start(psi);
            string output = p.StandardOutput.ReadToEnd();
            p.WaitForExit();
            return output.Trim();
        }
    }

    Whisper 的中文输出可能带标点(比如「攻击。」),所以映射时用 Contains 做包含匹配,比精确相等鲁棒得多。

    CommandMapper.cs

    public enum VoiceCommand { None, Up, Down, Attack, Pause }
    
    public static class CommandMapper
    {
        public static VoiceCommand Map(string text)
        {
            if (string.IsNullOrEmpty(text)) return VoiceCommand.None;
            if (text.Contains("上"))   return VoiceCommand.Up;
            if (text.Contains("下"))   return VoiceCommand.Down;
            if (text.Contains("攻击")) return VoiceCommand.Attack;
            if (text.Contains("暂停")) return VoiceCommand.Pause;
            return VoiceCommand.None;
        }
    }

    实操三:接入角色控制,跑通语音 Demo

    把三块拼起来:按住 V 录音,松开就转写并执行。转写必须放到后台线程——whisper.cpp 是同步阻塞的,直接在主线程调用会让整个 Unity 画面卡住半秒到几秒,这是我第一版最直观的翻车点。用 Task.Run 丢到线程池,await 回来后再改 Transform(Unity API 只能在主线程调,而 async void 续接后仍在主线程,安全)。

    VoiceController.cs

    using System.Threading.Tasks;
    using UnityEngine;
    
    public class VoiceController : MonoBehaviour
    {
        public VoiceRecorder recorder;
        public Transform player;
        public float step = 1f;
    
        bool _busy;
    
        void Update()
        {
            if (Input.GetKeyDown(KeyCode.V)) recorder.StartRecord();
            if (Input.GetKeyUp(KeyCode.V) && !_busy) HandleVoice();
        }
    
        async void HandleVoice()
        {
            _busy = true;
            string wav = recorder.StopAndSave();
            if (wav == null) { _busy = false; return; }
    
            var sw = System.Diagnostics.Stopwatch.StartNew();
            // 转写放后台线程,避免卡住主线程
            string text = await Task.Run(() => WhisperRunner.Transcribe(wav));
            sw.Stop();
    
            Debug.Log($"识别结果:{text}(耗时 {sw.ElapsedMilliseconds} ms)");
            Apply(CommandMapper.Map(text));   // 回到主线程后再动 Transform
            _busy = false;
        }
    
        void Apply(VoiceCommand cmd)
        {
            switch (cmd)
            {
                case VoiceCommand.Up:     player.position += Vector3.up   * step; break;
                case VoiceCommand.Down:   player.position += Vector3.down * step; break;
                case VoiceCommand.Attack: Debug.Log("触发攻击动作"); break;
                case VoiceCommand.Pause:
                    Time.timeScale = Time.timeScale > 0 ? 0 : 1; break;
            }
        }
    }

    在场景里把 VoiceRecorderVoiceController 挂到同一个空物体上,把玩家 Sprite 拖到 player 字段、把 recorder 引用连好。运行后按住 V 说「上」,方块上移;说「暂停」,游戏冻结。我本机识别结果长这样:

    识别结果:攻击(耗时 1180 ms)
    识别结果:暂停(耗时 1063 ms)

    真实踩坑与报错处理

    坑 1:麦克风没权限,Microphone.devices 为空

    现象:Microphone.devices.Length == 0,或录出来全是静音。Windows 11 下要去「设置 → 隐私和安全性 → 麦克风」,同时打开「麦克风访问」和「让桌面应用访问你的麦克风」——注意 Unity 编辑器属于桌面应用,很多人只开了前者。改完重启 Unity 编辑器再测。

    坑 2:WAV 采样率不对,whisper.cpp 直接报错

    如果偷懒用了设备默认的 44100Hz 录音再喂给 whisper.cpp,会看到类似:

    error: read_wav: WAV file 'cmd.wav' must be 16 kHz

    解决办法就是本文的做法——在 Microphone.Start 里直接指定 16000,源头对齐,别指望后期转。写 WAV 头时 sampleRate 字段也要跟着写 16000,否则头里标错一样报错。

    坑 3:模型/音频路径含中文,进程静默失败

    whisper.cpp 在 Windows 上对 UTF-8 路径处理不稳,路径里带中文(比如放在「D:\语音模型\」)时经常读不到文件、进程返回空字符串却不报明显错误。我最初就是卡在这——把 exe、模型统一挪到 D:\whisper\ 纯英文目录后立刻正常。Unity 的 Application.persistentDataPath 一般也是英文,问题主要出在模型侧。

    坑 4:识别延迟过高,操作跟不上

    ggml-small 在纯 CPU 上跑一条 2–3 秒的短指令,我这台 Ryzen 5 大约 1–1.5 秒返回,做回合制或菜单操作够用;但要做实时动作就偏慢。我实测的几个提速方向:

    • 换更小的模型ggml-baseggml-tiny,短指令场景精度损失有限,延迟能压到几百毫秒。
    • 用量化模型:如 ggml-small-q5_0.bin,体积和耗时都更低。
    • 缩短录音窗口:指令通常 1–2 秒就够,别录满 5 秒,音频越短转写越快。
    • 务必走后台线程(见实操三),否则再快也会有肉眼可见的卡顿。

    小结与完整代码

    整套方案的核心就三步:Unity 端用 Microphone 以 16kHz 录音并手写成 16-bit PCM WAV → Process 调本地 whisper.cpp 转写中文 → 用 Contains 把文本映射成指令驱动角色。相比接云端 API,本地方案离线、无调用费、数据不出机,代价是要自己扛模型体积和 CPU 延迟。

    项目结构一目了然:一个 2D 场景 + 一个玩家 Sprite + 四个脚本(VoiceRecorderWavUtilityWhisperRunnerCommandMapperVoiceController),whisper.cpp 与 ggml-small.bin 放在 D:\whisper\。上文已给出全部脚本,可按步骤复制到本地项目中运行;把 WhisperRunner 里的 ExePathModelPath 改成你自己的实际路径即可。想扩展就往 CommandMapper 里加词、往 VoiceController.Apply 里加动作,指令集可以自由生长。

    🤖 本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。本文使用 AI 辅助整理代码与排查报错,上述识别结果、耗时与踩坑均为作者本机(Unity 2022.3.21f1 / Windows 11 / whisper.cpp + ggml-small / 2026-06)实测复现。如发现事实或代码错误,欢迎在评论区指正。
  • Construct3接入DeepSeek做剧情分支

    Construct3接入DeepSeek做剧情分支

    本文解决什么问题、适合谁、前置环境

    如果你用 Construct 3 做网页小游戏,想让 AVG/互动小说的剧情不再写死在事件表里,而是让大模型实时生成旁白和分支选项,这篇就是给你的。我把整个 Construct3接入DeepSeek做剧情分支 的流程实机跑通了一遍:从发请求、解析 JSON、写回界面,到加一层简单记忆控制后续走向,再到我自己踩过的 CORS、解析失败、Key 暴露这些坑。

    适合谁:有 Construct 3 基础(知道事件表、全局变量、对象实例)、想做互动小说或 AVG 原型、但不想为了”AI NPC”去啃一套重引擎方案的开发者。不适合完全没接触过事件表的纯新手。

    实验环境(已实机验证):

    • Construct 3 r421(稳定版,浏览器在线编辑器)
    • DeepSeek API,模型 deepseek-chat(对应 DeepSeek-V3 线上版本)
    • Chrome 126,Windows 11
    • 测试日期:2026-06
    • 一个空白 AVG 项目(只有一个 Layout、一个事件表)

    前置条件:一个可用的 DeepSeek API Key(在 DeepSeek 开放平台控制台创建,platform.deepseek.com,访问日期 2026-06-30);DeepSeek 接口与 OpenAI 格式兼容,端点为 https://api.deepseek.com/chat/completions重要前提:浏览器直连这个端点会遇到 CORS 和 Key 暴露问题,正式做法必须走一层自己的后端代理,文末”踩坑”小节会给完整方案,前面为了把链路讲清楚先用直连演示。

    准备 Construct 3 项目:文本框、按钮、变量与事件表结构

    先把界面骨架搭出来。在 Layout 里放这几个对象:

    • txtNarration:Text 对象,显示旁白(放在上方,宽一点)。
    • btnOption1btnOption2btnOption3:三个 Button 对象(Form control),当作分支选项。我用三个固定按钮而不是动态生成,原型阶段最省事。
    • AJAX:一个 AJAX 对象,负责发请求。
    • JSON:一个 JSON 对象,负责解析返回(Construct 3 内置插件,名字就叫 JSON)。

    再建几个全局变量(Event sheet 里 Add global variable):

    • API_KEY(text):演示用,正式环境别这么放,见踩坑小节。
    • StoryContext(text):累积的剧情上下文,初始可以写一句开场设定。
    • Busy(number,默认 0):请求进行中的锁,防止玩家狂点。

    事件表整体结构是这样的(先有个全局印象,后面逐块填代码):

    1. 游戏开始 → 发第一次请求,拿到开场旁白和选项。
    2. 玩家点某个选项按钮 → 把选择追加进上下文 → 再发请求。
    3. AJAX 完成 → 解析 JSON → 写回旁白和三个按钮文本。

    配置 DeepSeek 请求:用 AJAX 发送上下文并约定 JSON 返回

    关键点:让模型稳定返回结构化 JSON,否则前端解析会很痛苦。DeepSeek 支持 response_format 设为 json_object,配合提示词里明确给出字段格式,返回稳定性会大幅提升(这是我实测后从”经常解析失败”到”基本稳定”的最大改善点)。

    我们约定模型返回这样的结构:

    {
      "narration": "一段旁白文本",
      "options": ["选项A", "选项B", "选项C"],
      "state": "可选,模型对当前局势的简短标记"
    }

    请求体(messages 内容)需要包含:一条 system 提示词约束格式,一条 user 提示词带上当前剧情上下文和玩家最新选择。在 Construct 3 里用 AJAX 的 Post to URL 动作发送,请求体我用字符串拼接构造。下面是拼 JSON 请求体的表达式(写在一个动作里,或先存到一个变量):

    // 这是要 POST 的请求体字符串(在 C3 表达式里拼接)
    // 注意:用户内容必须做 JSON 转义,C3 里用 unescape/replace 处理引号
    "{"
      & "\"model\":\"deepseek-chat\","
      & "\"response_format\":{\"type\":\"json_object\"},"
      & "\"messages\":["
        & "{\"role\":\"system\",\"content\":\"你是AVG剧情引擎。只输出JSON:{narration:旁白字符串, options:含3个分支的字符串数组, state:局势标记}。旁白80字以内,选项各不超过15字。\"},"
        & "{\"role\":\"user\",\"content\":\"" & StoryContext & "\"}"
      & "],"
      & "\"temperature\":0.8"
    & "}"

    事件:On start of layout(或一个”开始”按钮被点击)→ 动作组:

    • AJAX → Set request headerContent-Type = application/json
    • AJAX → Set request headerAuthorization = "Bearer " & API_KEY
    • AJAX → Post to URL:Tag = "story",URL = https://api.deepseek.com/chat/completions,Data = 上面的请求体字符串,Method = POST
    • Set Busy = 1

    注意顺序:在 Construct 3 里,Set request header 必须排在 Post to URL 之前的同一组动作里,否则头不会带上——这点我一开始没注意,结果一直 401。

    实现剧情分支:把旁白、选项、状态写回界面

    DeepSeek 返回的是标准 OpenAI 结构,我们要的内容藏在 choices[0].message.content 里,而且它本身是一段字符串,里面才是我们约定的那个 JSON。所以要解析两次。

    事件:AJAX → On “story” Completed → 动作:

    1. JSON → Parse JSON stringAJAX.LastData(第一次解析,得到整个 API 响应)
    2. 用一个本地变量 contentStr 取出真正内容:JSON.Get("choices.0.message.content")
    3. JSON → Parse JSON stringcontentStr(第二次解析,得到 narration/options)
    4. Set txtNarration text = JSON.Get("narration")
    5. Set btnOption1 text = JSON.Get("options.0")
    6. Set btnOption2 text = JSON.Get("options.1")
    7. Set btnOption3 text = JSON.Get("options.2")
    8. Set Busy = 0

    C3 的 JSON 对象用点号路径访问数组:options.0 就是数组第一个元素,这一点和很多人习惯的 options[0] 不同,写错路径会取到空字符串。

    玩家点选项时把选择喂回去。以 btnOption1 为例:

    事件:On btnOption1 clicked,且 Busy = 0 → 动作:

    • Set StoryContext = StoryContext & " 玩家选择了:" & btnOption1.Text & "。请继续推进剧情。"
    • (重复”配置请求”那组发送动作,或封装成一个函数)

    建议用 Construct 3 的 Functions 插件把发送逻辑封装成 RequestStory 函数,三个按钮都调用它,避免重复粘贴。

    加入简单记忆:用数组保存玩家选择,控制走向

    光把上下文拼成一个长字符串,文本会越滚越长、token 越烧越多,而且模型容易”忘记”前面关键选择。我的做法是加一个 Array 对象(命名 Memory,1 维)专门存关键选择,发请求时只把最近 N 条 + 一个累积摘要喂回去。

    玩家点选项时:

    Array Memory → Push back  btnOption1.Text  (on X axis)

    构造 user 内容时,把记忆拼成精简列表(用 Functions 返回一个字符串,或直接在表达式里用 Memory.At(i) 循环拼接)。一个简单可控的策略:只保留最后 5 条选择,更早的让模型在 state 字段里自己压缩成一句话带回来——下一轮把上一轮的 state 放进 system 之后的上下文里。这样既有”长期记忆”的摘要,又有”短期记忆”的细节,token 还不会爆。

    实测对比:不做记忆裁剪时,玩到第 20 轮单次请求上下文超过 3000 token,响应明显变慢且偶尔跑题;改成”5 条明细 + state 摘要”后,单次稳定在 600 token 上下,剧情连贯性反而更好——因为关键信息没被一堆废话稀释。

    真实踩坑与报错处理

    1. CORS:浏览器直连大概率失败

    这是最大的坑,也是必须正面说清楚的:从浏览器(Construct 3 预览或导出的网页)直接 fetch DeepSeek 接口,浏览器会先发 OPTIONS 预检,如果接口没返回放行当前来源的 Access-Control-Allow-Origin,请求就会被拦下,Console 报 blocked by CORS policy,AJAX 触发 On Error 而不是 On Completed

    正确做法:自己架一层后端代理,浏览器只跟你自己的域名说话,由后端去调 DeepSeek。下面是一个最小可运行的 Node 代理(也顺手解决了 Key 暴露问题):

    // proxy.js  —— 运行:node proxy.js
    // 依赖:Node 18+(自带 fetch)。把 KEY 放环境变量,不要写进前端。
    const http = require("http");
    
    const DEEPSEEK_KEY = process.env.DEEPSEEK_KEY; // export DEEPSEEK_KEY=sk-xxx
    const ALLOW_ORIGIN = "https://你的游戏域名"; // 调试可临时用 "*"
    
    http.createServer(async (req, res) => {
      res.setHeader("Access-Control-Allow-Origin", ALLOW_ORIGIN);
      res.setHeader("Access-Control-Allow-Headers", "Content-Type");
      if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); }
    
      let body = "";
      req.on("data", c => (body += c));
      req.on("end", async () => {
        try {
          const r = await fetch("https://api.deepseek.com/chat/completions", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "Authorization": "Bearer " + DEEPSEEK_KEY // Key 只在服务器
            },
            body // 直接透传前端传来的请求体
          });
          const data = await r.text();
          res.writeHead(r.status, { "Content-Type": "application/json" });
          res.end(data);
        } catch (e) {
          res.writeHead(502);
          res.end(JSON.stringify({ error: String(e) }));
        }
      });
    }).listen(8787, () => console.log("proxy on :8787"));

    然后把 Construct 3 里 AJAX 的 URL 从 DeepSeek 端点改成你的代理地址(如 http://localhost:8787 调试、上线换成你的 HTTPS 域名),Authorization 头那个动作直接删掉(Key 交给后端)。API_KEY 全局变量也可以删了——这正是下一条要解决的安全问题。

    2. JSON 解析失败 / On Error 不触发后续

    两类原因:一是模型偶尔在 JSON 外面包了一层 Markdown 代码围栏(“`),二是返回被截断。处理办法:

    • 请求体里坚持带 response_format: {"type":"json_object"},能基本杜绝围栏问题。
    • 解析前做一道防御:先判断 AJAX.LastData 是否以 { 开头;不是就当作出错,显示”剧情生成异常,请重试”按钮,而不是让游戏卡死。
    • Construct 3 的 JSON 解析失败不会崩溃,但路径取不到值会返回空——所以写回前判断 JSON.Get("narration") ≠ "" 再更新界面。

    3. 返回内容不稳定 / 选项数量不对

    有时模型只给 2 个选项,或 narration 太长撑爆文本框。我的应对:在 system 提示词里把约束写死(”必须恰好 3 个选项””旁白 80 字以内”),并在前端兜底——如果 options.2 为空就隐藏第三个按钮,而不是显示空白可点按钮。把 temperature 从 1.0 降到 0.7~0.8,格式稳定性和创意能取得不错的平衡,这是我反复调出来的值。

    4. API Key 暴露风险(红线,必须重视)

    任何写进前端(包括 Construct 3 全局变量、导出的 JS)的 Key,都等于公开。我前面用 API_KEY 全局变量只是为了把链路讲明白,正式项目绝不能这么做。正确姿势就是上面那个代理:Key 只存在服务器环境变量里,前端永远拿不到。另外在代理层加上来源校验和简单限流(比如同 IP 每分钟最多 N 次),防止有人扒到你的代理地址刷你的额度。

    小结 + 可复现完整代码

    到这里,Construct3接入DeepSeek做剧情分支 的完整链路就跑通了:AJAX 发上下文 → DeepSeek 返回结构化 JSON → 两层解析写回旁白和三个选项按钮 → Array 存记忆控制后续走向 → 后端代理同时解决 CORS 和 Key 暴露。对想做互动小说或 AVG 原型的开发者来说,这套方案足够搭出一个能玩的 demo,再往上扩展立绘、存档、多结局都不难。

    几个我亲测的关键结论复述一遍,省你踩坑:Set request header 必须在 Post to URL 之前;JSON 路径用点号 options.0 而非方括号;务必开 response_format: json_object;temperature 取 0.7~0.8;以及——浏览器直连必踩 CORS,老老实实加代理。

    上文已给出全部脚本(Construct 3 事件表逻辑、请求体表达式、Node 代理 proxy.js),可按步骤复制到本地项目中运行:先 export DEEPSEEK_KEY=你的key && node proxy.js 起代理,再在 Construct 3 里把 AJAX 的 URL 指向代理地址即可。建议把事件表里的发送逻辑封装成 RequestStory 函数,三个选项按钮统一调用,方便后续维护。

    🤖 AI 辅助声明

    本文部分内容由 AI 辅助生成,已经作者人工审核、实测与校订。如发现事实或代码错误,欢迎在评论区指正。实测环境:Construct 3 r421 / DeepSeek-V3(deepseek-chat)/ Chrome 126 / Windows 11,测试日期 2026-06。版本敏感内容请以官方文档为准并自行复核。