码道 | 用提示词当游戏引擎:AI 狼人杀从零到一开发全记录

当 DeepSeek AI 不仅能回答问题,还能扮演狼人、预言家、女巫,在一场完整的桌游中互相博弈、推理、投票、伪装——你会发现,「提示词工程」的边界远比想象中宽广。这篇文章记录了我用纯 HTML + CSS + JavaScript 开发一款 AI 驱动狼人杀网页游戏的完整历程,从架构设计到提示词协议、从流式输出到身份保密,每一步踩过的坑和最终的方案都毫无保留地分享给你。


一、项目缘起:让 AI 不只是聊天

运行效果展示:在这里插入图片描述
在这里插入图片描述
使用工具展示:在这里插入图片描述

1.1 一个简单的想法

狼人杀是一款经典的社交推理游戏,它的核心魅力在于人与人之间的博弈——伪装、试探、逻辑推理、情绪操控。传统的狼人杀要么需要一群人围坐桌前,要么依赖专门的游戏平台。但如果把其中的"人"换成 AI 呢?

想象一下这样的场景:

  • 7 名玩家围坐一桌,其中 6 名是 AI,1 名是你
  • AI 玩家拥有不同的性格和发言风格——有人冷静理性像侦探,有人活泼话痨像大学生,有人毒舌犀利像职场精英
  • 夜晚,狼人 AI 会根据场上局势选择刀杀目标;预言家 AI 会查验可疑玩家;女巫 AI 会权衡是否使用珍贵的解药
  • 白天,每个 AI 玩家会根据自己的身份和信息,发表一段符合人设的发言,分析局势、踩人、辩护、试探
  • 投票环节,AI 会基于所有发言和逻辑推理,投出自己认为最可疑的那个人
  • 而你,作为唯一的真人玩家,亲自参与这场博弈——夜晚行动、白天发言、投票放逐,都由你来决定

这不是科幻场景,这是我用 DeepSeek AI + 纯前端技术实现的一个完整项目。

1.2 为什么用纯前端

很多人可能会问:为什么不搭个后端?为什么不用 React/Vue?

答案很简单:门槛。一个纯 HTML + CSS + JavaScript 的项目,双击就能运行,不需要安装 Node.js,不需要配置环境,不需要运行 npm install。任何人克隆代码、打开浏览器就能玩。这是降低传播门槛的关键。

而且,GitCode AI 开放接口支持 CORS 跨域请求,这意味着浏览器可以直接调用 AI 接口,完全不需要后端代理。这为纯前端方案提供了技术可行性。

1.3 核心挑战

在动手之前,我梳理了几个核心挑战:

  1. 如何让 AI 输出结构化数据:AI 的输出是自然语言文本,但游戏引擎需要结构化的指令(“刀杀谁”、“查验谁”、“投给谁”)。如何通过提示词让 AI 严格返回 JSON?
  2. 如何管理游戏状态:狼人杀有复杂的阶段流转(夜晚→白天→投票→结算),如何用纯 JavaScript 实现一个可靠的状态机?
  3. 如何让 AI 扮演不同角色:同一个 AI 模型,如何让它在不同身份下表现出不同的行为模式?狼人要伪装,预言家要报查验,女巫要权衡用药?
  4. 如何处理流式输出:AI 的响应是流式的,如何在前端实现打字机效果,同时确保最终解析的 JSON 是正确的?
  5. 如何避免剧透:玩家参与模式下,如何确保 AI 的夜晚行动不会泄露给不该知道的玩家?

这些问题,我将在后面的章节中逐一解答。


二、技术架构:零依赖的优雅

2.1 整体架构

项目采用纯前端零依赖架构,所有功能通过原生 HTML5 + CSS3 + JavaScript(ES6+)实现:

ai-werewolf/
├── index.html              # 主页面:大厅 + 对局界面
├── css/
│   └── style.css           # 深色狼人主题样式
├── js/
│   ├── api-config.js       # API 密钥配置
│   ├── api.js              # AI 接口封装:fetch 流式调用 + JSON 提取
│   ├── prompts.js          # 系统提示词模板(核心:定义 JSON 协议)
│   ├── players.js          # 角色池 / 7 名 AI 人格 / 身份分配
│   ├── game.js             # 游戏状态机:昼夜流转 / 投票 / 胜负判定
│   └── app.js              # UI 渲染 / 真人操作面板 / 游戏编排
├── test/
│   ├── run.js              # 状态机模拟测试
│   └── api-smoke.js        # 真实 API 冒烟测试
└── README.md

每个文件职责单一、边界清晰,可以独立理解和测试。这种设计让代码更容易维护,也让开发过程中定位问题更加高效。

2.2 模块职责

模块职责依赖
api-config.js存放 API 密钥和模型配置
api.js封装 fetch 流式调用、SSE 解析、JSON 提取、重试api-config.js
prompts.js为每个游戏阶段生成系统提示词players.js
players.js定义角色池、AI 人格、身份分配逻辑
game.js游戏状态机,控制昼夜流转和胜负判定api.jsprompts.jsplayers.js
app.jsUI 渲染、事件绑定、真人决策交互game.js

依赖方向是单向的:app.jsgame.js{api.js, prompts.js, players.js}api-config.js。没有循环依赖,没有跨层调用。

2.3 为什么不用框架

React/Vue 确能提升开发效率,但对这个项目来说:

  • 项目规模小:7 个文件、不到 2000 行代码,框架的收益不明显
  • 运行门槛:框架需要构建步骤(webpack/vite),而纯 HTML 双击即用
  • 学习成本:读者克隆代码后不需要了解框架就能读懂

纯 JavaScript 的 IIFE 模式(立即执行函数表达式)配合全局变量,足以管理这个规模的代码。如果项目后续扩展到更大规模,再引入框架也不迟。


三、核心设计:提示词即游戏引擎

这是整个项目最核心、最有趣的部分。

3.1 问题:如何让 AI 输出结构化指令

AI 模型的输出是自然语言文本。但游戏引擎需要的是结构化指令:

  • 狼人决定刀杀谁 → 需要 {target: 3}
  • 预言家决定查验谁 → 需要 {target: 5}
  • 女巫决定是否用药 → 需要 {save: true, kill: null}
  • 玩家发言 → 需要 {speech: "我是好人,怀疑5号..."}
  • 投票 → 需要 {target: 3, reason: "..."}

如果 AI 返回的是自然语言(“我决定刀杀3号玩家”),前端还需要用正则或 NLP 来解析,既不可靠又复杂。

解决方案:通过系统提示词约定 JSON 输出协议。

3.2 JSON 协议设计

我设计了一套完整的 JSON 协议,覆盖游戏中所有可能的 AI 动作:

type阶段字段说明
night_wolf夜晚-狼人target, reason狼人团队选择刀杀目标
night_seer夜晚-预言家target预言家选择查验目标
night_witch夜晚-女巫save, kill女巫决定用药策略
day_speak白天-发言speech玩家公开发言内容
vote白天-投票target, reason投票放逐目标
all_claim猎人开枪shoot猎人出局时开枪目标
game_over终局winner, summary游戏结束总结

每个动作都是一个合法的 JSON 对象,前端通过 type 字段分发处理。这就是"提示词即游戏引擎"的含义——游戏的规则不是用代码硬编码的,而是通过提示词描述给 AI,让 AI 按规则输出结构化指令,前端只负责执行指令。

3.3 提示词模板

以狼人夜晚行动的提示词为例:

function promptWolf(game, wolfPlayers) {
  const wolfNames = wolfPlayers.map(p => `${p.id + 1}${p.name}`).join('、');
  return [
    systemRule(),
    `当前是【夜晚-狼人行动】阶段。你是 ${wolfNames},狼人阵营全体成员。
存活玩家:${describeAlive(game.players)}
狼人团队(自己人):${wolfNames}
${describeSpeeches(game)}
现在狼人团队开会,需要共同选择一名玩家在今晚杀掉。请输出:
{"type":"night_wolf","target":<被刀玩家编号>,"reason":"<简短行动理由>"}`
  ].join('\n\n');
}

提示词包含:

  1. 系统总纲:定义 JSON 输出铁律、玩家编号规则、决策依据
  2. 当前阶段:明确告诉 AI 现在是什么阶段、它扮演什么角色
  3. 游戏上下文:存活玩家列表、历史发言摘要、私密信息(如狼人队友)
  4. 输出格式:明确要求输出的 JSON 结构

3.4 系统总纲:AI 的"游戏规则书"

所有提示词共享一个系统总纲,它是 AI 的"游戏规则书":

function systemRule() {
  return `你正在参与一场真人级别的「狼人杀」在线对局。
【铁律】
1. 你每一次回复都只能输出一个合法的 JSON 对象,不允许输出任何多余的文字。
2. 玩家编号从 1 开始。所有 target 字段的值都必须来自提示中列出的存活玩家编号。
3. 不知道或想弃权时,相关数字字段用 null 表示。
4. 你拥有完整的情报与理性:狼人阵营会共同决策、互不背叛;好人阵营全力以赴找狼。
5. 你的决策依据:自己的身份、夜晚技能得到的信息、所有人公开发言中的漏洞、票型逻辑。`;
}

这段提示词的关键在于:

  • 铁律 1 确保 AI 只输出 JSON,不输出多余文字
  • 铁律 2 确保目标编号合法
  • 铁律 4 赋予 AI “阵营意识”——狼人会保护队友,好人会找狼
  • 铁律 5 明确决策依据,让 AI 的行为更合理

3.5 私密信息管理

不同角色在相同阶段看到的信息是不同的。提示词需要为每个角色提供适当的私密信息:

  • 狼人:知道队友是谁(“你的狼人队友是:6号 顾言”)
  • 预言家:知道自己之前的查验结果(“你之前查验过:3号=好人,5号=狼人”)
  • 女巫:知道今晚被刀的是谁、解药和毒药是否已使用
  • 村民/猎人:只知道公开信息(死亡名单、发言记录)

这种信息隔离是通过提示词动态注入实现的——每个角色的提示词只包含该角色应该知道的信息。AI 不知道的信息,它自然无法在发言中泄露。

3.6 实测验证

在实际测试中,DeepSeek AI 对这套 JSON 协议的遵循度非常高。以下是真实 API 冒烟测试的结果:

✔ 狼人夜晚行动: type=night_wolf | target=4
  解析结果: {"type":"night_wolf","target":4,"reason":"首夜无发言,选择中间位..."}
✔ 预言家夜晚: type=night_seer | target=1
✔ 女巫夜晚: type=night_witch | save=false, kill=null
✔ 白天发言: type=day_speak | speech="..."
✔ 投票: type=vote | target=1, reason="暂无有效发言,随机排水..."

5 类动作全部 100% 返回合法 JSON,type 字段完全匹配协议定义。这证明了"提示词即游戏引擎"的可行性。


四、AI 人格设计:让对局生动不重复

4.1 为什么需要人格

如果 7 名 AI 玩家都用同一个"默认人格",对局会非常乏味——所有人的发言风格都一样,推理方式都一样,就像 7 个克隆人在对话。

差异化的人格设计能让对局生动起来:冷静的人会做逻辑分析,活泼的人会用夸张语气,毒舌的人会嘲讽别人,憨厚的人会直来直去。这不仅让观战体验更好,也让真人玩家感觉自己在和"不同的人"博弈。

4.2 七名 AI 玩家

我设计了 7 名拥有固定名字和差异化性格的 AI 玩家:

编号名字头像性格关键词发言风格
1林默🧑‍💻冷静理性、侦探话不多但观察力极强,喜欢用"我注意到""这很可疑"开头
2苏小玥👧活泼话痨、直觉型热情洋溢,喜欢夸张语气词,动不动说"我超有第六感的!"
3老赵🧔老谋深算、话少犀利一开口就是重点,口头禅"年轻人,想太多了"
4白夜🦹高冷毒舌、职场精英毒舌但精准,喜欢用"就这?""你也太天真了"嘲讽
5阿凯🧑‍🚒阳光憨厚、体育生耿直真诚,经常说"我这个人很直的"
6顾言🧑‍🎓严谨逻辑、辩论队长喜欢列点分析,见谁都说"我们来盘逻辑"
7楚然👩‍⚕️温柔稳重、心理咨询师先安抚情绪再分析,总爱说"大家冷静一下"

4.3 人格注入提示词

人格信息会注入到白天发言的提示词中:

// 在 promptSpeak 中
`你是 ${speaker.id + 1}${speaker.name},身份:${myRole}。
你的人设:${speaker.persona}
...
请进行一段符合你人设的发言(80~200字)...`

AI 会根据人设调整发言的语气、用词和推理方式。实测中,"白夜"的发言确实带有毒舌风格,"苏小玥"的发言确实活泼话痨,"顾言"的发言确实喜欢列点分析。人格设计有效地让每局对局都独一无二。

4.4 身份与人设的分离

重要设计决策:身份是随机分配的,人设是固定的。

这意味着"林默"这局可能是狼人,下局可能是预言家。人设不变,但身份变化会导致行为模式完全不同——同样是"冷静理性"的林默,作为狼人时会冷静地伪装和甩锅,作为预言家时会冷静地报查验和盘逻辑。

这种分离让游戏有更高的重玩价值:你永远不知道"毒舌的白夜"这局是好人还是狼人。


五、游戏状态机:昼夜流转的精密齿轮

5.1 状态流转

狼人杀的核心是一个复杂的状态机:

INIT → NIGHT_WOLF → NIGHT_SEER → NIGHT_WITCH →
DAY_DEATH → DAY_SPEAK(循环) → VOTE → DAY_RESULT →
(猎人开枪) → 胜负判定 → [继续 NIGHT_WOLF] 或 GAME_OVER

每个状态的进入和退出都有严格的条件。比如:

  • 夜晚必须按"狼人→预言家→女巫"的顺序行动
  • 白天发言必须所有存活玩家都发完才能进入投票
  • 投票后必须检查是否平票,平票则无人出局
  • 出局后必须检查是否猎人,猎人可以开枪
  • 每次死亡后必须检查胜负条件

5.2 异步流程控制

由于每个 AI 动作都需要等待 API 响应,整个状态机是异步的。我用 async/await 实现了顺序执行:

async start() {
  // 分配身份
  this.players = createGamePlayers(this.humanIndex);
  // 第一夜
  await this.runNight();
  // 第一天
  await this.runDay();
  // 循环直到游戏结束
  while (!this.winner && !this.stopped) {
    await this.sleep(400);
    await this.runNight();
    if (this.winner) break;
    this.deadToday = [];
    await this.runDay();
  }
  // 终局总结
  if (this.winner) await this.gameOver();
}

5.3 夜晚阶段

夜晚阶段按固定顺序执行三个角色的行动:

async runNight() {
  // 1. 狼人刀人
  const aliveWolves = wolvesOf(this.players);
  if (aliveWolves.length) {
    const leader = aliveWolves.find(p => p.isHuman) || aliveWolves[0];
    const act = await this.decide('wolf', { player: leader, prompt: promptWolf(...) });
    // 处理刀杀目标
  }
  // 2. 预言家查验
  const seer = this.players.find(p => p.alive && p.role === 'seer');
  if (seer) {
    const act = await this.decide('seer', { player: seer, prompt: promptSeer(...) });
    // 记录查验结果
  }
  // 3. 女巫决策
  const witch = this.players.find(p => p.alive && p.role === 'witch');
  if (witch) {
    const act = await this.decide('witch', { player: witch, prompt: promptWitch(...) });
    // 处理解药/毒药
  }
}

注意 decide 方法是 AI 决策和真人决策的统一入口——如果当前玩家是真人,走 UI 交互流程;如果是 AI,走 API 调用流程。这种设计让观战模式和参与模式可以共用同一套状态机代码。

5.4 白天阶段

白天阶段更复杂,包含公布死讯、轮流发言、投票、结算四个子阶段:

async runDay() {
  // 公布昨夜死亡
  if (this.lastKilledId != null) {
    this.killPlayer(this.lastKilledId, '被狼人刀杀');
  }
  // 轮流发言
  for (const p of this.aliveSeatOrder()) {
    const act = await this.decide('speak', { player: p, prompt: promptSpeak(...) });
    this.log({ type: 'speech', text: act.speech });
  }
  // 投票
  const votes = {};
  for (const p of this.aliveSeatOrder()) {
    const act = await this.decide('vote', { player: p, prompt: promptVote(...) });
    votes[act.target] = (votes[act.target] || 0) + 1;
  }
  // 计票结算
  // ... 找出得票最多者,处理平票、猎人开枪
}

5.5 胜负判定

胜负判定遵循标准狼人杀规则:

checkWin() {
  const wolves = this.players.filter(p => p.isWolf);
  // 狼人全灭 → 好人胜
  if (wolves.every(p => !p.alive)) return 'good';
  // 狼人数 ≥ 好人存活数 → 狼人胜
  const wolfAlive = wolves.filter(p => p.alive).length;
  const goodAlive = this.players.filter(p => !p.isWolf && p.alive).length;
  if (wolfAlive >= goodAlive) return 'wolf';
  // 村民全灭 → 狼人胜
  if (villagers.every(p => !p.alive)) return 'wolf';
  // 神职全灭 → 狼人胜
  if (gods.every(p => !p.alive)) return 'wolf';
  return null;
}

5.6 目标合法性校验

AI 返回的 target 不一定合法——可能指向已出局玩家、自己、或狼人队友。resolveTarget 方法负责校验:

resolveTarget(val, opts = {}) {
  if (val == null) return null;
  const id = Number(val);
  const p = this.players.find(x => x.id === id - 1);
  if (!p || id < 1 || id > this.players.length) return null;
  if (opts.aliveOnly && !p.alive) return null;
  if (opts.excludeSelf != null && p.id === opts.excludeSelf) return null;
  if (opts.excludeWolves && p.isWolf) return null;
  return p;
}

如果 AI 返回了非法目标,resolveTarget 返回 null,该轮行动被视为无效。这种防御性设计确保了游戏状态不会因为 AI 的错误输出而崩溃。


六、AI 接口封装:流式调用与 JSON 提取

6.1 流式调用

DeepSeek API 支持 SSE(Server-Sent Events)流式输出。流式调用的好处是用户能看到 AI 逐字输出,体验更自然。

async function chat(messages, { onDelta, signal, retries = 2 } = {}) {
  const res = await fetch(cfg.apiUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.apiKey },
    body: JSON.stringify({
      model: cfg.model, messages, stream: true,
      max_tokens: cfg.maxTokens, temperature: cfg.temperature,
      top_p: cfg.topP, thinking_budget: cfg.thinkingBudget
    }),
    signal
  });
  const full = await streamRead(res.body, onDelta, signal);
  return full;
}

streamRead 函数逐行解析 SSE 数据,提取 delta.content 增量文本,通过 onDelta 回调实时通知前端:

async function streamRead(body, onDelta, signal) {
  const reader = body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '', full = '';
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    // 按 \n 分行解析 SSE
    // 提取 data: 前缀的 JSON
    // 获取 delta.content 增量
    // onDelta(piece) 通知前端
  }
  return full;
}

6.2 JSON 提取的稳健性

AI 的输出不总是完美的。有时它会在 JSON 前后加一些文字,有时会用 markdown 代码块包裹,有时输出会截断。extractJson 方法负责从任意文本中稳健地提取 JSON:

function extractJson(text) {
  let t = text.trim();
  // 剥掉 markdown 代码块围栏
  t = t.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
  const start = t.indexOf('{');
  if (start < 0) return null;
  // 从第一个 { 起,按括号配对找到完整对象
  let depth = 0, inStr = false, esc = false;
  for (let i = start; i < t.length; i++) {
    const ch = t[i];
    if (inStr) {
      if (esc) esc = false;
      else if (ch === '\\') esc = true;
      else if (ch === '"') inStr = false;
      continue;
    }
    if (ch === '"') { inStr = true; continue; }
    if (ch === '{') depth++;
    else if (ch === '}') {
      depth--;
      if (depth === 0) return JSON.parse(t.slice(start, i + 1));
    }
  }
  return null;
}

这个方法的关键在于括号配对算法:从第一个 { 开始,逐字符扫描,跟踪字符串上下文和嵌套深度,当深度归零时截取完整 JSON。这种方法能正确处理字符串内的括号、嵌套对象等各种边界情况。

6.3 重试机制

网络请求可能失败,AI 输出可能不合法。chat 方法内置了重试机制:

for (let attempt = 0; attempt <= retries; attempt++) {
  try {
    // 调用 API
    return full;
  } catch (e) {
    if (e.name === 'AbortError') throw e;  // 用户主动中断不重试
    lastErr = e;
    if (attempt < retries) await sleep(800 * (attempt + 1));  // 指数退避
  }
}
throw new Error(lastErr.message);

6.4 AbortController:优雅中断

当用户点击"新一局"时,正在进行中的 AI 调用需要被中断。AbortController 是浏览器原生 API,可以取消 fetch 请求:

// game.js constructor
this.abortController = new AbortController();

// decide 方法
text = await AI.chat(messages, { onDelta, signal: this.abortController.signal });

// stop 方法
stop() {
  this.stopped = true;
  this.abortController.abort();
}

中断后,fetch 会抛出 AbortError,状态机的 catch 块检测到这是用户主动中断,不显示错误信息,直接退出。


七、前端界面:深色月夜下的博弈

7.1 视觉设计

界面采用深色月夜主题,营造狼人杀的氛围感:

  • 背景:深蓝黑径向渐变 + 月亮光晕 + 漂浮迷雾
  • 卡片:玻璃拟态(backdrop-filter blur)
  • 配色:狼人红 #c0392b、暖金 #d89b56、好人绿 #4fae7c
  • 字体:系统中文字体,标题用渐变金色

7.2 布局

对局界面采用三栏布局:

┌──────────────────────────────────────────┐
│  顶栏:阶段指示 | 轮次 | 身份开关 | 新一局  │
├──────────┬───────────────────┬───────────┤
│  座位区   │   对局时间线       │  操作面板  │
│  7个座位  │   发言/行动日志    │  真人决策  │
│  身份标识  │   流式打字机效果   │  按钮/输入 │
├──────────┴───────────────────┴───────────┤
│  状态栏:当前阶段提示                      │
└──────────────────────────────────────────┘

响应式设计:桌面三栏,平板两栏,手机单栏堆叠。

7.3 流式打字机效果

AI 发言时,前端会实时显示逐字输出的效果。关键在于流式输出时只显示提取出的发言内容,而非原始 JSON:

function onStreamPiece({ playerId, text }) {
  rawStreamText += text;
  // 从原始 JSON 中提取 speech 字段
  const speech = extractSpeechFromPartial(rawStreamText);
  if (speech == null) return;  // 还没提取到,不显示
  streamTarget.textContent = speech;  // 只显示发言内容
}

function extractSpeechFromPartial(text) {
  const match = text.match(/"speech"\s*:\s*"((?:[^"\\]|\\.)*)/);
  if (!match) return null;
  return JSON.parse('"' + match[1] + '"');
}

这个设计解决了早期版本中"AI 原始 JSON 代码暴露在发言区"的问题——用户看到的始终是干净的发言文字,而非 {"type":"day_speak","speech":"..."}

7.4 真人决策面板

当轮到真人玩家行动时,操作面板会根据当前阶段显示不同的交互界面:

  • 狼人行动:显示存活好人玩家的按钮列表,点击选择刀杀目标
  • 预言家行动:显示存活玩家按钮(排除自己),点击选择查验目标
  • 女巫行动:显示"使用解药"/“使用毒药”/"蛰伏"选项,毒药需选择目标
  • 白天发言:显示文本输入框,输入发言内容
  • 投票:显示存活玩家按钮 + 弃权选项
  • 猎人开枪:显示存活玩家按钮 + 不开枪选项

每种交互都是"选择即提交"——点击按钮后立即将决策传回游戏状态机,无需额外的确认步骤。

7.5 身份保密机制

参与模式下,身份保密至关重要。设计了两层保密:

第一层:座位卡显示

  • 自己的身份始终可见
  • 其他玩家的身份取决于"上帝视角"开关
  • 关闭上帝视角时,其他玩家显示"❓ 身份保密"

第二层:日志保密

  • 夜晚行动日志标记为 secret: true
  • 关闭上帝视角时,appendLog 方法跳过 secret 日志
  • 玩家不会看到"狼人决定刀杀3号"这类剧透信息
function appendLog(e) {
  // 参与模式下隐藏夜晚行动与隐秘信息
  if (game && !game.showAllRoles && e.secret) return;
  // ... 正常渲染
}

八、开发历程中的挑战与解决方案

8.1 挑战一:AI 思维链泄露

问题:DeepSeek API 会返回 reasoning_content 字段(AI 的内心推理过程)。最初我把 contentreasoning_content 都拼进了显示文本,导致狼人的内心戏全部暴露——"我应该刀杀3号因为他可能是预言家"这类本该保密的推理直接显示在了界面上。

解决:在 SSE 解析中只提取 delta.content,忽略 delta.reasoning_content。AI 的思维链在后台默默运行,用户只看到最终的发言和行动。

// 修改前(错误)
const piece = (delta && (delta.content || delta.reasoning_content)) || '';

// 修改后(正确)
const piece = (delta && delta.content) || '';

8.2 挑战二:JSON 代码暴露在发言区

问题:流式输出期间,前端直接显示了 AI 返回的原始文本,也就是 {"type":"day_speak","speech":"我是好人..."} 这样的 JSON 字符串。用户看到的是代码而非发言内容。

解决:在流式输出回调中,实时从累积的原始文本中提取 speech 字段,只显示提取出的发言内容。通过正则匹配 "speech":"..." 的部分内容,支持流式渐进提取。

8.3 挑战三:密钥管理与 401 错误

问题:纯前端项目中 API 密钥存放在浏览器端。最初密钥文件被 .gitignore 排除,用户克隆后需要手动填写。但"浏览器保存的密钥优先于内置密钥"的逻辑导致用户保存过错误密钥后,即使内置了正确密钥也持续 401。

解决

  1. 将密钥直接内置到项目中并提交(个人项目的务实选择)
  2. 添加"清除已保存密钥"按钮
  3. 添加"测试连接"按钮,一键诊断密钥/网络/时间问题
  4. 开始游戏时自动使用输入框的值,避免"填了没保存"的问题

8.4 挑战四:新一局按钮无反应

问题:点击"新一局"后,界面没有切换回大厅。原因是正在进行中的 AI 调用(await fetch)阻塞了状态机循环,game.stop() 只设置了 stopped 标志,但循环要等当前 AI 调用返回后才会检查标志。

解决:引入 AbortControllerstop() 时调用 abort() 真正中断 fetch 请求。中断后抛出 AbortError,状态机的 catch 块检测到这是主动中断,静默退出不报错。

8.5 挑战五:file:// 协议的 CORS 问题

问题:用户双击 index.html 打开时,页面运行在 file:// 协议下,fetch 跨域请求 AI 接口可能被浏览器阻止。

解决:实测发现 GitCode AI 接口对 Origin: null(file:// 的 origin)返回 Access-Control-Allow-Origin: null,CORS 预检通过。因此双击打开也能正常调用 AI。同时在 README 中提供 python -m http.server 方式作为备选。

8.6 挑战六:自动滚动

问题:点击"开始游戏"后,页面切换到对局界面,但内容没有自动滚动到可见区域,用户需要手动滑滚轮才能看到日志。

解决

  1. 切换界面后调用 window.scrollTo(0, 0) 滚到顶部
  2. 时间线区域设置固定高度 height: calc(100vh - 140px) 而非 max-height,确保内容溢出时出现滚动条
  3. 每条新日志追加后,scrollBottomrequestAnimationFrame 将滚动位置设为 scrollHeight

九、测试策略:从 Mock 到真实 API

9.1 状态机模拟测试

用 Node.js 的 vm 模块加载游戏代码,Mock AI 的 chat 方法返回预设 JSON,验证状态机的完整流程:

ctx.__AI.chat = async (messages) => {
  const prompt = messages[0].content;
  if (prompt.includes('夜晚-狼人')) return JSON.stringify({type:'night_wolf', target:...});
  if (prompt.includes('白天-发言')) return JSON.stringify({type:'day_speak', speech:'...'});
  // ...
};
const g = new ctx.__Game({ humanIndex: -1, callbacks: {} });
await g.start();
assert(g.winner !== null, '有胜负判定');
assert(g.logs.length > 10, '产生足够日志');

这种测试不消耗 API 配额,能快速验证状态机的正确性。

9.2 真实 API 冒烟测试

用真实 DeepSeek API 测试每类提示词,验证 AI 对 JSON 协议的遵循度:

const prompts = [
  ['狼人夜晚', ctx.promptWolf(g, wolves)],
  ['预言家夜晚', ctx.promptSeer(g, seer)],
  ['白天发言', ctx.promptSpeak(g, g.players[0])],
  // ...
];
for (const [label, prompt] of prompts) {
  const text = await ctx.__AI.chat([{role:'system', content:prompt}]);
  const obj = ctx.__AI.extractJson(text);
  assert(obj && obj.type, `${label}: 返回合法 JSON`);
}

9.3 浏览器 E2E 测试

用 Playwright 启动 Chromium,模拟用户完整操作流程:打开页面 → 选择玩家 → 开始游戏 → 等待 AI 推进 → 验证日志和阶段 → 测试新一局按钮。捕获控制台错误和页面错误,确保零报错。


十、项目展示

10.1 大厅界面

大厅页面展示游戏标题、玩家位置选择、上帝视角开关、API 密钥配置和开始按钮。深色月夜主题配合玻璃拟态卡片,营造出桌游的氛围感。

10.2 对局界面

对局界面分为三栏:

  • 左侧座位区:7 个玩家座位卡,显示头像、名字、存活状态和身份标识
  • 中间时间线:所有游戏事件的日志流,包括系统公告、玩家发言、投票记录、死亡通知
  • 右侧操作面板:当轮到真人玩家时,显示对应的交互界面

10.3 游戏流程

一局完整的游戏流程:

  1. 开局分配身份,公布"你扮演X号,身份是Y"
  2. 夜晚:狼人选择刀杀目标 → 预言家查验 → 女巫用药
  3. 白天:公布死讯 → 存活玩家轮流发言 → 投票放逐 → 猎人开枪
  4. 胜负判定:狼人全灭好人胜,好人全灭狼人胜
  5. 终局:AI 生成战后复盘总结

十一、使用指南

11.1 快速开始

git clone https://atomgit.com/yjb5201314yjb520/ruan6yaojiabing.git
cd ruan6yaojiabing
# 双击 index.html 或运行本地服务器
python3 -m http.server 8000
# 访问 http://localhost:8000

11.2 游戏操作

  1. 在大厅选择你要扮演的玩家位置
  2. 设置上帝视角开关(新手建议开启)
  3. 点击"开始游戏"
  4. 根据操作面板提示进行夜晚行动、白天发言、投票
  5. 游戏结束后可点击"新一局"重新开始

11.3 密钥配置

项目已内置 API 密钥,开箱即用。如遇 401 错误:

  1. 点击"🧹 清除已保存密钥"按钮
  2. 点击"🔧 测试连接"按钮诊断问题
  3. 检查电脑系统时间是否正确(AI 服务器会校验时间)

十二、技术亮点总结

亮点说明
提示词即引擎游戏规则通过提示词描述给 AI,AI 输出 JSON 指令驱动游戏,而非硬编码逻辑
JSON 协议设计7 种动作类型覆盖全部游戏阶段,AI 100% 遵循格式
差异化 AI 人格7 名 AI 玩家拥有不同性格和发言风格,对局生动不重复
流式打字机实时提取 speech 字段,只显示发言内容,不暴露原始 JSON
身份保密机制两层保密(座位卡 + 日志),参与模式下不剧透
AbortController优雅中断 AI 调用,"新一局"按钮即时响应
零依赖纯前端双击即用,无需安装任何环境
响应式设计桌面三栏、平板两栏、手机单栏自适应

十三、未来展望

这个项目还有很多可以扩展的方向:

  1. 更多角色:守卫、白痴、丘比特等扩展角色
  2. 多人在线:用 WebSocket 实现真人多人对战,AI 填补空缺位置
  3. 对局回放:记录完整对局数据,支持回放和分享
  4. AI 难度调节:通过提示词调整 AI 的推理深度和伪装水平
  5. 统计分析:统计各角色胜率、各 AI 人格的表现数据
  6. 移动端适配:针对手机触摸操作优化交互体验
  7. 多语言支持:英文版提示词和界面国际化
  8. 自定义规则:让玩家自定义角色配置和游戏规则

十四、写在最后

这个项目让我深刻体会到:提示词工程的边界远比想象中宽广。当大多数人还在用 AI 写代码、翻译、总结文章时,AI 已经可以扮演一个有性格、有策略、有伪装能力的狼人杀玩家了。

项目的核心创新不在于技术多复杂——纯前端、零依赖、不到 2000 行代码——而在于用提示词定义游戏规则,用 JSON 协议连接 AI 和游戏引擎。这种"提示词即引擎"的思路,可以推广到任何回合制、有明确规则的场景:剧本杀、德州扑克、甚至简单的棋牌游戏。

如果你对这个项目感兴趣,欢迎克隆代码体验。如果你有想法或建议,欢迎交流。

仓库地址:https://atomgit.com/yjb5201314yjb520/ruan6yaojiabing
技术栈:HTML5 + CSS3 + 原生 JavaScript + DeepSeek AI
模型:deepseek-ai/DeepSeek-V4-Flash


本文由项目开发者撰写,记录了 AI 狼人杀从零到一的完整开发历程。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐