码道:纯前端实现永不枯竭的猜字谜AI小游戏
码道 | 从零打造"永远玩不完"的猜字谜 AI 小游戏:DeepSeek 驱动的纯前端实践


一、缘起:一个会"枯竭"的小游戏
想写猜字谜小游戏的人很多,但大多数做出来以后都有同一个尴尬:题库是有限的。传统做法是预置几百条字谜,玩家玩上两三天,翻来覆去就是那几道题。谜面背得滚瓜烂熟之后,游戏的乐趣就荡然无存了。
于是我在想:如果谜题不是"查出来的",而是"生成出来的",会怎样?
答案就是本文的主角——猜字谜 AI 小游戏。它的题目不是任何一份静态题库,而是每一次点击「下一题」时,由 DeepSeek 大模型现场实时构思的一道全新谜题。你可以把它理解成一个"永不枯竭的出题机器":天底下再大的题库,也装不下大模型的想象力。
这个项目的技术选型有一个非常鲜明的特点:它是纯前端项目。HTML5 + CSS3 + JavaScript,零框架、零构建、零依赖,整个项目只有三个 JS 文件、一个 CSS 文件、一个 HTML 文件,加起来不到一千行代码。你甚至不需要安装 npm 包,用一个静态服务器把目录跑起来就能玩。
本文将完整记录这个项目从灵感到落地再到测试交付的全过程,包括:
- 为什么要用 AI 出题,以及如何设计"让 AI 乖乖回 JSON"的系统提示词
- 如何把 Python 的流式请求代码移植到浏览器端(fetch + SSE 解析)
- 判题逻辑里那些容易被忽略的细节(全角半角、标点、空格)
- 开发中踩过的坑:CORS、表单提交刷新页面、AI 的"内心独白"
- 中国风 UI 的设计思路
- 如何用 Playwright 做完整的端到端验证
无论你是想做一个类似的 AI 小游戏,还是想了解"如何让大模型输出结构化数据",这篇文章都值得一读。
二、灵感来源:一段 Python 示例代码
说起灵感,其实来自一段非常朴素的 Python 示例代码。调用 OpenAI 兼容接口时,Python 端通常这样写:
def query(payload):
response = requests.post(API_URL, headers=headers, json=payload, stream=True)
for line in response.iter_lines():
if not line.startswith(b"data:"):
continue
if line.strip() in (b"data:[DONE]", b"data: [DONE]"):
return
yield json.loads(line.decode("utf-8").lstrip("data:"))
这段代码做了三件事:请求带 stream=True、逐行迭代服务器返回的 SSE 流、解析 data: 前缀的 JSON 行。整个模式非常清晰。
但问题来了——这段代码只能在 Python 里跑。我的目标是一个纯前端的网页游戏,没有后端,没有 Python 环境。我必须在浏览器里复刻这段逻辑。
浏览器端的"网络层"工具是 fetch,它和 requests 最大的区别在于:fetch 配合 ReadableStream 可以实现真正的流式读取——服务器发一块、浏览器收一块,不需要等全部响应结束。这正好是 SSE 流式对话的基本盘。
于是就有了 js/api.js 里这个浏览器版的流式解析器:
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 });
let newlineIndex;
while ((newlineIndex = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload);
const delta = chunk.choices?.[0]?.delta;
if (delta && typeof delta.content === "string" && delta.content) {
onDelta?.(delta.content);
}
}
}
可以看到,核心思路完全是 Python 版的"移植":切行、剥掉 data: 前缀、判 [DONE]、解析 JSON、取增量文本。区别在于:
- 按行解析:Python 的
iter_lines()在浏览器里要靠"缓冲 + 找换行符"手工实现。因为网络包可能把一个完整的 SSE 事件拆成两半发过来,所以先用buffer拼起来,再逐行消费。 - 中文字节安全:
TextDecoder("utf-8")的第二参数{ stream: true }很关键,它会正确处理跨包切开的多字节字符,避免中文出现乱码。 - 防御式解析:
JSON.parse包了一层 try/catch,万一某一行是半截数据,直接跳过等下一行,绝不中断整个流。
这段代码是项目的"心脏",后面所有的玩法都建立在它之上。
三、最核心的设计:让 AI 输出结构化 JSON
3.1 为什么必须让 AI 返回 JSON
游戏要正常渲染,需要这些信息:谜面、谜底、类别、提示、解析。如果让 AI 自由发挥,它可能会回一段长篇小说,也可能把谜底写在第一句、解析写在最后一句,前端想提取简直是一场噩梦。
所以关键的一步是:在系统提示词里规定死输出格式。开源模型对格式遵循的能力远比想象中强——只要你把约束写得足够清楚、足够具体。
我最初的设计是这样的:
你是「猜字谜AI小游戏」的主持人。
请出一道有趣、难度适中的中文谜题(谜底为一个汉字或二字词语),
并严格只输出一个 JSON 对象,不要输出任何其它文字、解释、思考过程或 markdown 代码块标记。
返回 JSON 的字段定义如下:
{
"riddle": "谜面文字",
"answer": "谜底,一个汉字或二字词语",
"category": "谜底类别,只能是以下之一:动物/植物/自然/生活用品/食物/人体/成语/其他",
"hint": "给玩家的提示,必须含蓄,不能直接出现谜底",
"explanation": "揭晓谜底后的一句话解析"
}
注意几个细节:
category枚举化:如果不限定可选值,AI 可能输出"季节"“气象”"文字游戏"之类五花八门的类别,前端徽章的配色就没法做。限定八个枚举值后,前端完全可控。hint字段明确"不能直接出现谜底":否则提示会变成"答案是秋"这种泄底事故。加一句约束,效果立竿见影。- 明确禁止 markdown 包裹:很多模型习惯用 ```json 代码块包住 JSON,前端还要多一层解析。索性在提示词里就禁止。
实测下来,模型 99% 的情况下会严格遵循这个格式。即便偶尔抽风,前端也做了兜底(见 3.3)。
3.2 流式返回带来的挑战:JSON 是"拼"出来的
流式接口下有一个容易被忽视的问题:AI 是一个字一个字往外蹦的。也就是说,“{”、“riddle”、“一边绿”……这些内容可能分散在几十个网络包里。你不能指望关闭流式、一次性拿到完整 JSON——因为流式响应的延迟更低、体验更好,我们当然要流式。
那么问题来了:前端什么时候知道"JSON 完整了"?
答案很简单:等 [DONE] 事件。流式过程中,onDelta 只管把 delta.content 拼到一个字符串里;等流结束时,再对拼接好的完整文本做 JSON.parse。这个策略我在 generateRiddle 里实现:
async function generateRiddle(onDelta, signal) {
let content = "";
await streamChat({
messages: [
{ role: "system", content: RIDDLE_SYSTEM_PROMPT },
{ role: "user", content: "请开始出题。" }
],
onDelta: (text) => { content += text; onDelta?.(text); },
signal
});
return parseRiddleText(content);
}
3.3 容错解析:AI 不守规矩怎么办
虽然提示词已经尽了最大努力,但模型毕竟是概率模型,保不齐哪天就多回了一句"好的!这是为你准备的谜题:{…}",或者真的用 ```json 把 JSON 包了起来。为了不把体验搞砸,parseRiddleText 做了三层容错:
- 去掉首尾的
json /包裹; - 找到第一个
{和最后一个},截取中间内容尝试解析; - 全部失败则抛出友好错误,前端显示"点击重试"。
function parseRiddleText(text) {
let cleaned = (text || "").trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/```\s*$/, "");
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start >= 0 && end > start) {
return JSON.parse(cleaned.slice(start, end + 1));
}
throw new Error("AI 返回内容无法解析为 JSON");
}
这个函数看起来很简单,但它决定了整个游戏在"AI 不配合"时是优雅降级还是一崩到底。面向 AI 应用的开发,防御性编程不是可选项,是必需品。
四、游戏逻辑:判题比想象中讲究
谜题拿到了,接下来是判题。这看起来是最没技术含量的一步——“比较两个字符串而已”。但如果你真的写过判题器,就知道里面有无数边角料。
先看一个简单版:
function normalizeAnswer(str) {
return String(str || "")
.trim()
.toLowerCase()
.replace(/[\uFF01-\uFF5E]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
.replace(/[\s"'“”‘’·,,。.!!??、;;::()()【】\[\]《》<>~~\-_—]/g, "");
}
这个函数干了四件事:
trim()去首尾空格:玩家不小心打了" 秋 ",不能判错。toLowerCase()转小写:万一谜底是英文词(虽然字谜基本不会),大小写要统一。- 全角转半角:这是中文输入场景的经典坑。玩家可能输入全角的英文字母或数字(“,”),也可能输入全角空格。
\uFF01-\uFF5E是全角符号区,减去0xFEE0就换算成半角。 - 剔除所有空格和标点:中文标点用户随手就敲,答案里也不该出现标点。
你以为这就完了?还有更隐蔽的坑。
坑一:答案可能本身就是多音字写法的变体。 比如 AI 给出谜底"秋",玩家输入"秋。“,归一化后都是"秋”,判对。但如果 AI 的谜底是"东西",玩家输入"东 西"(中间有空格),归一化后变成"东西"一样判对。这些都是细节,细节决定体验。
坑二:不要用"包含"比较。 有些简陋的判题器用"玩家输入是否包含谜底"来判断,结果玩家随手输入"秋天来了"四个字,竟然判对了。正确的做法是完全相等。用户输入什么就是什么,不含糊。
坑三:AI 可能给空谜底。 概率很低,但万一 answer 字段是空的,判题逻辑会直接暴露。所以我在前端加了一道防线:谜底为空就提示"本题谜底异常,请换一题"。
判对了之后,游戏进入"胜利状态":
- 统计加一:已解、连胜都 +1,最佳连胜取历史最大值;
- 展示解析:把 AI 给的
explanation渲染在绿色反馈卡里; - 禁用输入:输入框、按钮全部锁定,防止重复提交;
- 出现「下一题」按钮:点一下,又是一道全新的谜题。
判错的时候,每次尝试都会计数并显示"第 N 次尝试",给玩家一点小小的压力感,也方便答对后展示"猜了 N 次"的成就感。
五、交互与 UI:中国风不是贴个红就能糊弄的
5.1 设计语言:朱砂 × 宣纸 × 鎏金
字谜是典型的中国文化产品,UI 自然要走中国风。但我对"中国风"有自己的理解:不是大红大绿、不是金龙盘柱,而是颜色和质感里透出的克制与温润。
最终定下的配色哲学只有三个关键词:
- 宣纸米(
#faf6ee):做页面底色。暖而不腻,有纸张的温润感; - 朱砂红(
#b03a2e):做主按钮、类别徽章、标题。这抹红是"印章"的存在,克制地点缀,绝不铺满; - 鎏金(
#c99b2f):做成功态、下一题按钮、轮播高光。金色在中国文化里代表"喜"与"成"。
为了不让大色块显得呆板,我还做了几个小细节:
- 卡片顶部有一条 6px 的渐变金红线动画,从左到右缓缓流动,像一条金线划过,暗示"AI 正在生成"的灵动感;
- 谜面用 衬线/Song 系字体(
STKaiti、KaiTi),配合letter-spacing: 2px的字间距,读出"字帖"的韵味;而功能按钮用无衬线字体,保证交互可读性; - 背景用两团 径向渐变"晕染"——右上角一晕鎏金、左下角一晕朱砂,像水墨在宣纸上洇开,比纯色底更有层次。
5.2 状态机的设计:三种态,一个都不能少
任何异步交互界面都有状态机问题。这个游戏的状态比想象的要多:
- 出题中(loading):转圈 Spinner + “AI 正在构思谜题…”。这一态很关键——AI 生成题面需要几秒,如果没这层,用户会以为页面死了。
- 出题失败(error):显示 😵 图标 + 失败原因 + 「重新出题」按钮。网络抖动、Key 过期、超时都在这一态兜住。
- 猜谜中(game):谜面、输入框、三个操作按钮(提示 / 看答案 / 下一题)。
我在 game.js 里用一个 GAME.loading 布尔值 + 三个视图容器(loadingView / gameView / errorView)管理切换,用 hidden 属性控制显隐。同时所有操作函数开头都会校验状态:
function submitAnswer() {
if (GAME.loading || !GAME.current || GAME.solved) return;
// ...
}
三个闸门:出题中不许提交;没有题目不许提交;已经答对/揭晓不许提交。这种"先查状态再干事"的写法,杜绝了一大批竞态问题。
5.3 换题竞态:一个必须处理的问题
流式请求有个经典竞态:用户点了「下一题」,但上一个请求还在流式输出中。如果不去管它,两个请求的增量可能拼在一起,或者先发出去的请求后返回,把新题覆盖掉。
我的解法是 AbortController:
async function loadNewRiddle() {
if (GAME.controller) GAME.controller.abort(); // 掐断旧请求
GAME.controller = new AbortController(); // 开启新请求
try {
const riddle = await generateRiddle(null, GAME.controller.signal);
// ...
} catch (err) {
if (err.name === "AbortError") return; // 被中断是正常的,静默处理
showError(...); // 真正的失败才提示
}
}
被 abort() 中断的 fetch 会抛 AbortError,这个必须和真正的网络错误区分开——前者是"用户主动换题",后者才是"出题失败"。就这一行的区分,让用户在快速连点时体验依然顺滑。
六、开发中踩过的坑(都是血泪)
6.1 坑一:CORS 到底支不支持?
第一个直觉:纯前端调第三方 API,大概率被 CORS 拦死。所以我第一时间做了预检测试:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:8000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH
Access-Control-Allow-Headers: Cookie,Authorization,Origin,content-type,...
看到 Access-Control-Allow-Origin 回显请求来源,心里的石头落地了——gitcode 的模型服务直接支持浏览器跨域调用。这意味着"纯前端小游戏"的架构完全成立,不用搭后端、不用开代理,一个静态页面就能跑起来。
这个结论直接决定了整个项目的架构复杂度:没有后端。所有逻辑都在浏览器里,部署只需要一个静态服务器。
6.2 坑二:表单提交把页面"刷没了"
这是开发中第一个让页面"白屏"的 bug。我在 HTML 里把提交按钮写成了:
<form class="answer-area">
<input ... />
<button type="submit">猜!</button>
</form>
点击「猜!」或按回车后——页面刷新了!分数清零、谜题重出,一片混乱。
原因很简单:表单默认行为是提交并跳转。我没写 action,也没拦 submit 事件,浏览器就老老实实地把页面重新加载了一遍。
修复方式很规范:监听 submit 事件并 preventDefault:
answerForm.addEventListener("submit", (e) => {
e.preventDefault();
submitAnswer();
});
这样保留 form 语义(回车即可提交),又完全掌控行为。记住:但凡页面上有输入框,优先用 form + submit 事件,而不要让按钮自己去绑 click。 这是可访问性和健壮性兼顾的最佳实践。
6.3 坑三:AI 的"内心独白"污染了 JSON
第一次接入流式接口时,我发现 choices[0].delta 里有两个字段:
{ "delta": { "reasoning_content": "让我们想想……", "content": "" } }
reasoning_content 是模型的思考过程——它会在内心盘算"这道谜题出什么好呢""要不换个难度"之类的话。前几十个网络包里全是它,content 一直为空。如果我把 delta 整个拼接,JSON 会被思考过程直接污染,解析直接失败。
解法很干脆:只取 content,忽略 reasoning_content。
const delta = chunk.choices?.[0]?.delta;
if (delta && typeof delta.content === "string" && delta.content) {
onDelta?.(delta.content);
}
这样玩家的体验是:点「下一题」→ 转圈几秒 → 谜题完整出现。思考过程被安全地丢弃,同时模型的思考还保证了谜题的质量。
6.4 坑四:Node 测试没问题,浏览器里却不行
开发时我用 Node 直接跑 fetch + 流式解析,一切正常。但放到浏览器里,第一次端到端测试就翻车了。
排查过程给了我一个教训:Node 的 fetch 没有 CORS 概念,浏览器有。Node 里能通的请求,浏览器里可能因为预检失败直接报 TypeError: Failed to fetch。
但这次不是接口的问题(前面已经验证过接口支持 CORS),而是我自己测试脚本的问题——我用 Puppeteer 拦截了请求做 mock,却忘了给 mock 响应加 CORS 头,导致浏览器校验失败。这也提醒我:写工具、写测试时,mock 要尽量贴近真实响应的行为,包括响应头。
七、测试验证:自动化端到端,心里才有底
一个纯前端项目,怎么证明"它能用"?我选择了 Playwright(环境里恰有 Chromium),写了一套真实的浏览器端到端测试。最有价值的是"路由拦截 + 真实链路"双模式:
7.1 拦截模式:确定性覆盖所有交互
第一段测试用固定的 SSE 数据 mock 接口,保证题目固定(谜底"秋"),从而能断言每一个交互分支:
渲染 OK: 一边绿,一边红 / 自然
错误反馈 OK
提示 OK
答对 OK,已解 =1
流程覆盖:页面加载 → 谜面渲染 → 输入错误答案(断言红色错误反馈)→ 点提示(断言提示出现)→ 输入正确答案(断言绿色成功反馈、计数 +1、下一题按钮出现)。确定性数据 + 全交互断言,让回归测试有了坚实基础。
7.2 真实链路模式:验证 CORS 与流式
第二段测试放开拦截,让浏览器真的去调 API。等 90 秒内题面出现,确认整条链路通:
浏览器真实API题面: 有土能种庄稼,有水能养鱼虾…
这一步验证的是"真实世界能不能用":CORS 预检、正式请求、流式分块、增量拼接、JSON 解析、DOM 渲染——一条龙全通。我还在 Node 侧做了流式解析的单测(纯函数层),并把"```json 包裹、前后夹字、纯垃圾文本"三类脏输入都跑了一遍容错逻辑。
测试的意义不仅是"证明没 bug",更是"证明需求被满足了"。 这个项目的验收标准就一句话:打开页面,一题接着一题,猜得下去。
八、玩法与体验:一个完整的小游戏闭环
最终的产品体验是:
- 打开页面,AI 自动出第一题,类别徽章 + 谜面大字展示;
- 输入答案回车,本地即时判定:
- 答对 → 绿色横幅"🎉 恭喜答对!",下方展示 AI 的解析,连胜 +1;
- 答错 → 红色横幅"❌ 不对哦,再想想~",尝试次数 +1;
- 卡壳了?点「💡 给个提示」,AI 给的含蓄提示缓缓展开;
- 彻底放弃?点「👀 看答案」,谜底揭晓,连胜中断但学到知识点;
- 点「下一题 →」,循环回到第 1 步,但这次是一道全新的题。
统计栏常驻三项:已解 / 连胜 / 最佳连胜,全部存在 localStorage 里,刷新页面不丢。
我特别喜欢第 4 步「看答案」的设计——它给了玩家一个"体面认输"的出口,同时用解析把认输变成一次学习。答对固然开心,看答案也不亏。
九、项目结构与工程化心得
最终的项目只有 8 个文件:
.
├── index.html # 页面结构
├── css/style.css # 中国风样式
├── js/
│ ├── config.js # AI 接口配置(Key/模型/参数集中于此)
│ ├── api.js # AI 调用 + SSE 解析 + JSON 容错
│ └── game.js # 游戏状态、判题、交互
├── README.md # 使用文档
└── docs/preview.png # 预览图
有几点工程心得值得沉淀:
- 配置与逻辑分离:API Key、模型名、温度这类"会变的东西"全部收敛到
config.js,业务代码一行不改就能换 Key、换模型、调参数。这对 AI 应用尤其重要——你随时可能想试试不同的模型。 - 职责单一:
api.js只管"和 AI 通信",game.js只管"游戏规则和界面"。两者通过一个generateRiddle函数解耦。想加"多道题批量生成"?只改api.js。 - 状态集中管理:
GAME全局对象集中管理 current / attempts / solved / loading / stats,配合局部校验,心智负担很小。 - 面向容错编程:凡是从外界(AI)拿数据的地方,都假设"它可能不守规矩",然后优雅兜底。这是 AI 时代前端开发的新常态。
十、可以怎么继续玩
这个项目随时可以扩展,我列几个方向:
- 对局计时:加一个 60 秒倒计时,答对加时,错了扣分——立刻变成"紧张版";
- 题目难度分级:让 AI 按"简单 / 中等 / 困难"三档出题,挑战不同难度解锁成就;
- 双人对战:WebSocket,两个人抢答同一道题,比手速;
- 音效与动效:答对撒花、答错震动,反馈更有情绪;
- 答题历史回放:把每道题的谜面/你的回答/解析存下来,生成"我的猜谜日记"。
每一个方向,改的都是游戏逻辑层,AI 那一层几乎不用动——这就是"AI 只负责出题、业务逻辑自己掌控"这种架构带来的红利。
十一、结语
回看整个项目,最有意思的地方在于:它把一个"内容供给"的问题,从"存量"变成了"增量"。 传统游戏面对的问题是"内容总有一天会耗尽",而接入大模型之后,内容供给变成了无限的。
但它也带来了新的工程问题:格式不可控、输出有延迟、偶尔会抽风。所以这个项目的核心经验可以浓缩成三句话:
- 用系统提示词把自由度收窄到可控范围——能枚举就枚举,能限定就限定;
- 对流式输出做缓冲式消费——拼完整再解析,同时对脏输入做防御;
- 给每一次远程调用兜底——超时、中断、失败,UI 上都要有交代。
如果你也想做一个"AI 原生"的小玩具,强烈建议从这类"小切口"入手:规则简单、内容靠 AI、交互即时反馈。它不会让你陷入复杂工程,却能让你把"AI 应用开发"的完整链路走一遍——从提示词设计,到流式解析,到状态管理,到容错兜底,再到自动化验证。
猜字谜是个小游戏,但它背后的方法论一点也不小。希望这篇文章对你有启发。如果有什么想法,欢迎在评论区交流。
(本文由华为云 CodeArts 协助整理,项目代码、README 与完整测试用例均已开源在仓库中,欢迎 Star 与 Fork。)
更多推荐


所有评论(0)