标签: Godot

  • 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 实机测试整理。如发现事实或代码错误,欢迎在评论区指正。

  • 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 解析、导出后连接失败)为作者复现记录。