码道 | 从零打造"永远玩不完"的猜字谜 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、取增量文本。区别在于:

  1. 按行解析:Python 的 iter_lines() 在浏览器里要靠"缓冲 + 找换行符"手工实现。因为网络包可能把一个完整的 SSE 事件拆成两半发过来,所以先用 buffer 拼起来,再逐行消费。
  2. 中文字节安全:TextDecoder("utf-8") 的第二参数 { stream: true } 很关键,它会正确处理跨包切开的多字节字符,避免中文出现乱码。
  3. 防御式解析: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 做了三层容错:

  1. 去掉首尾的 json / 包裹;
  2. 找到第一个 { 和最后一个 },截取中间内容尝试解析;
  3. 全部失败则抛出友好错误,前端显示"点击重试"。
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, "");
}

这个函数干了四件事:

  1. trim() 去首尾空格:玩家不小心打了" 秋 ",不能判错。
  2. toLowerCase() 转小写:万一谜底是英文词(虽然字谜基本不会),大小写要统一。
  3. 全角转半角:这是中文输入场景的经典坑。玩家可能输入全角的英文字母或数字(“,”),也可能输入全角空格。\uFF01-\uFF5E 是全角符号区,减去 0xFEE0 就换算成半角。
  4. 剔除所有空格和标点:中文标点用户随手就敲,答案里也不该出现标点。

你以为这就完了?还有更隐蔽的坑。

坑一:答案可能本身就是多音字写法的变体。 比如 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 状态机的设计:三种态,一个都不能少

任何异步交互界面都有状态机问题。这个游戏的状态比想象的要多:

  1. 出题中(loading):转圈 Spinner + “AI 正在构思谜题…”。这一态很关键——AI 生成题面需要几秒,如果没这层,用户会以为页面死了。
  2. 出题失败(error):显示 😵 图标 + 失败原因 + 「重新出题」按钮。网络抖动、Key 过期、超时都在这一态兜住。
  3. 猜谜中(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",更是"证明需求被满足了"。 这个项目的验收标准就一句话:打开页面,一题接着一题,猜得下去。

八、玩法与体验:一个完整的小游戏闭环

最终的产品体验是:

  1. 打开页面,AI 自动出第一题,类别徽章 + 谜面大字展示;
  2. 输入答案回车,本地即时判定:
    • 答对 → 绿色横幅"🎉 恭喜答对!",下方展示 AI 的解析,连胜 +1;
    • 答错 → 红色横幅"❌ 不对哦,再想想~",尝试次数 +1;
  3. 卡壳了?点「💡 给个提示」,AI 给的含蓄提示缓缓展开;
  4. 彻底放弃?点「👀 看答案」,谜底揭晓,连胜中断但学到知识点;
  5. 点「下一题 →」,循环回到第 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    # 预览图

有几点工程心得值得沉淀:

  1. 配置与逻辑分离:API Key、模型名、温度这类"会变的东西"全部收敛到 config.js,业务代码一行不改就能换 Key、换模型、调参数。这对 AI 应用尤其重要——你随时可能想试试不同的模型。
  2. 职责单一:api.js 只管"和 AI 通信",game.js 只管"游戏规则和界面"。两者通过一个 generateRiddle 函数解耦。想加"多道题批量生成"?只改 api.js。
  3. 状态集中管理:GAME 全局对象集中管理 current / attempts / solved / loading / stats,配合局部校验,心智负担很小。
  4. 面向容错编程:凡是从外界(AI)拿数据的地方,都假设"它可能不守规矩",然后优雅兜底。这是 AI 时代前端开发的新常态。

十、可以怎么继续玩

这个项目随时可以扩展,我列几个方向:

  • 对局计时:加一个 60 秒倒计时,答对加时,错了扣分——立刻变成"紧张版";
  • 题目难度分级:让 AI 按"简单 / 中等 / 困难"三档出题,挑战不同难度解锁成就;
  • 双人对战:WebSocket,两个人抢答同一道题,比手速;
  • 音效与动效:答对撒花、答错震动,反馈更有情绪;
  • 答题历史回放:把每道题的谜面/你的回答/解析存下来,生成"我的猜谜日记"。

每一个方向,改的都是游戏逻辑层,AI 那一层几乎不用动——这就是"AI 只负责出题、业务逻辑自己掌控"这种架构带来的红利。

十一、结语

回看整个项目,最有意思的地方在于:它把一个"内容供给"的问题,从"存量"变成了"增量"。 传统游戏面对的问题是"内容总有一天会耗尽",而接入大模型之后,内容供给变成了无限的。

但它也带来了新的工程问题:格式不可控、输出有延迟、偶尔会抽风。所以这个项目的核心经验可以浓缩成三句话:

  1. 用系统提示词把自由度收窄到可控范围——能枚举就枚举,能限定就限定;
  2. 对流式输出做缓冲式消费——拼完整再解析,同时对脏输入做防御;
  3. 给每一次远程调用兜底——超时、中断、失败,UI 上都要有交代。

如果你也想做一个"AI 原生"的小玩具,强烈建议从这类"小切口"入手:规则简单、内容靠 AI、交互即时反馈。它不会让你陷入复杂工程,却能让你把"AI 应用开发"的完整链路走一遍——从提示词设计,到流式解析,到状态管理,到容错兜底,再到自动化验证。

猜字谜是个小游戏,但它背后的方法论一点也不小。希望这篇文章对你有启发。如果有什么想法,欢迎在评论区交流。

(本文由华为云 CodeArts 协助整理,项目代码、README 与完整测试用例均已开源在仓库中,欢迎 Star 与 Fork。)

Logo

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

更多推荐