码道 | 用提示词当游戏引擎:AI 狼人杀从零到一开发全记录
码道 | 用提示词当游戏引擎: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 核心挑战
在动手之前,我梳理了几个核心挑战:
- 如何让 AI 输出结构化数据:AI 的输出是自然语言文本,但游戏引擎需要结构化的指令(“刀杀谁”、“查验谁”、“投给谁”)。如何通过提示词让 AI 严格返回 JSON?
- 如何管理游戏状态:狼人杀有复杂的阶段流转(夜晚→白天→投票→结算),如何用纯 JavaScript 实现一个可靠的状态机?
- 如何让 AI 扮演不同角色:同一个 AI 模型,如何让它在不同身份下表现出不同的行为模式?狼人要伪装,预言家要报查验,女巫要权衡用药?
- 如何处理流式输出:AI 的响应是流式的,如何在前端实现打字机效果,同时确保最终解析的 JSON 是正确的?
- 如何避免剧透:玩家参与模式下,如何确保 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.js、prompts.js、players.js |
app.js | UI 渲染、事件绑定、真人决策交互 | game.js |
依赖方向是单向的:app.js → game.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');
}
提示词包含:
- 系统总纲:定义 JSON 输出铁律、玩家编号规则、决策依据
- 当前阶段:明确告诉 AI 现在是什么阶段、它扮演什么角色
- 游戏上下文:存活玩家列表、历史发言摘要、私密信息(如狼人队友)
- 输出格式:明确要求输出的 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 的内心推理过程)。最初我把 content 和 reasoning_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。
解决:
- 将密钥直接内置到项目中并提交(个人项目的务实选择)
- 添加"清除已保存密钥"按钮
- 添加"测试连接"按钮,一键诊断密钥/网络/时间问题
- 开始游戏时自动使用输入框的值,避免"填了没保存"的问题
8.4 挑战四:新一局按钮无反应
问题:点击"新一局"后,界面没有切换回大厅。原因是正在进行中的 AI 调用(await fetch)阻塞了状态机循环,game.stop() 只设置了 stopped 标志,但循环要等当前 AI 调用返回后才会检查标志。
解决:引入 AbortController,stop() 时调用 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 挑战六:自动滚动
问题:点击"开始游戏"后,页面切换到对局界面,但内容没有自动滚动到可见区域,用户需要手动滑滚轮才能看到日志。
解决:
- 切换界面后调用
window.scrollTo(0, 0)滚到顶部 - 时间线区域设置固定高度
height: calc(100vh - 140px)而非max-height,确保内容溢出时出现滚动条 - 每条新日志追加后,
scrollBottom用requestAnimationFrame将滚动位置设为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 游戏流程
一局完整的游戏流程:
- 开局分配身份,公布"你扮演X号,身份是Y"
- 夜晚:狼人选择刀杀目标 → 预言家查验 → 女巫用药
- 白天:公布死讯 → 存活玩家轮流发言 → 投票放逐 → 猎人开枪
- 胜负判定:狼人全灭好人胜,好人全灭狼人胜
- 终局: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 游戏操作
- 在大厅选择你要扮演的玩家位置
- 设置上帝视角开关(新手建议开启)
- 点击"开始游戏"
- 根据操作面板提示进行夜晚行动、白天发言、投票
- 游戏结束后可点击"新一局"重新开始
11.3 密钥配置
项目已内置 API 密钥,开箱即用。如遇 401 错误:
- 点击"🧹 清除已保存密钥"按钮
- 点击"🔧 测试连接"按钮诊断问题
- 检查电脑系统时间是否正确(AI 服务器会校验时间)
十二、技术亮点总结
| 亮点 | 说明 |
|---|---|
| 提示词即引擎 | 游戏规则通过提示词描述给 AI,AI 输出 JSON 指令驱动游戏,而非硬编码逻辑 |
| JSON 协议设计 | 7 种动作类型覆盖全部游戏阶段,AI 100% 遵循格式 |
| 差异化 AI 人格 | 7 名 AI 玩家拥有不同性格和发言风格,对局生动不重复 |
| 流式打字机 | 实时提取 speech 字段,只显示发言内容,不暴露原始 JSON |
| 身份保密机制 | 两层保密(座位卡 + 日志),参与模式下不剧透 |
| AbortController | 优雅中断 AI 调用,"新一局"按钮即时响应 |
| 零依赖纯前端 | 双击即用,无需安装任何环境 |
| 响应式设计 | 桌面三栏、平板两栏、手机单栏自适应 |
十三、未来展望
这个项目还有很多可以扩展的方向:
- 更多角色:守卫、白痴、丘比特等扩展角色
- 多人在线:用 WebSocket 实现真人多人对战,AI 填补空缺位置
- 对局回放:记录完整对局数据,支持回放和分享
- AI 难度调节:通过提示词调整 AI 的推理深度和伪装水平
- 统计分析:统计各角色胜率、各 AI 人格的表现数据
- 移动端适配:针对手机触摸操作优化交互体验
- 多语言支持:英文版提示词和界面国际化
- 自定义规则:让玩家自定义角色配置和游戏规则
十四、写在最后
这个项目让我深刻体会到:提示词工程的边界远比想象中宽广。当大多数人还在用 AI 写代码、翻译、总结文章时,AI 已经可以扮演一个有性格、有策略、有伪装能力的狼人杀玩家了。
项目的核心创新不在于技术多复杂——纯前端、零依赖、不到 2000 行代码——而在于用提示词定义游戏规则,用 JSON 协议连接 AI 和游戏引擎。这种"提示词即引擎"的思路,可以推广到任何回合制、有明确规则的场景:剧本杀、德州扑克、甚至简单的棋牌游戏。
如果你对这个项目感兴趣,欢迎克隆代码体验。如果你有想法或建议,欢迎交流。
仓库地址:https://atomgit.com/yjb5201314yjb520/ruan6yaojiabing
技术栈:HTML5 + CSS3 + 原生 JavaScript + DeepSeek AI
模型:deepseek-ai/DeepSeek-V4-Flash
本文由项目开发者撰写,记录了 AI 狼人杀从零到一的完整开发历程。
更多推荐


所有评论(0)