标签: RPG任务日志

  • 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 实机跑通;讯飞星火接口与版本号可能随官方更新变动,接入前请对照最新官方文档核对。