
简介一份基于JavaScript的猜谜游戏完整源码包面向Web前端初学者、JavaScript学习者和游戏开发爱好者演示如何用原生JavaScript构建可交互的在线猜谜小游戏。项目围绕用户点击入场、随机谜题生成、答案校验、得分更新等环节清晰展示了事件监听、逻辑判断、DOM动态渲染等前端基础知识同时涉及try...catch异常处理、localStorage数据持久化和ES6模块化组织等进阶实践适合作为前端入门或课堂练手项目。压缩包共8个文件体积约1.12MB涵盖HTML页面、CSS样式表、JavaScript脚本、Markdown说明文档及JSON/YAML配置等类型并配有背景图与动图等视觉效果文件组织规范解压后即可对照运行与修改。目前已有138人学习下载。通过这个项目读者可以掌握addEventListener、Math.random、if...else、querySelector等常用API与语法理解浏览器端游戏开发的基本流程还能借鉴其数据保存与代码组织方式将模块化、错误处理等技巧迁移到其他Web项目中为后续构建更完整的前端应用打下扎实基础。1. 猜谜游戏的完整形态不依赖框架也能跑通全流程的 JavaScript 小项目猜谜游戏这个项目是我见过最适合拿来当 JavaScript 练手实例的完整小工程。它没有任何框架依赖也不涉及打包配置浏览器直接打开 index.html 就能玩但内部把题目数据、游戏状态、界面渲染、倒计时、提示系统拆成了分工清晰的模块。很多学过数组、对象、DOM 操作的人最大的困惑是「语法都认识连不成一个能跑的东西」这个项目正好把这根线串起来了。它适合刚学完前端基础的新手跑通一遍完整流程也适合初级开发者研究一个小游戏的状态管理方式或者直接替换题库数据改造成自己的问答小应用。整体代码量不大核心逻辑集中在一个 JS 文件里改动成本很低。2. 项目结构拆解从 index.html 到 questions.js 的分层职责2.1 目录文件一览与职责划分整个工程一共只有四个源文件没有 package.json没有 node_modules没有构建配置。结构上按「入口页面 → 样式 → 数据 → 逻辑」四层拆分跟工作中常见的前端小项目组织方式一致这一点比功能本身更有学习价值。guess-game/ ├── index.html # 页面入口只放 #app 容器与脚本引用 ├── style.css # 全部样式卡片式深色主题 └── js/ ├── questions.js # 题目数据纯数组对象 ├── timer.js # 倒计时模块独立可复用 └── game.js # 游戏主逻辑出题、判分、渲染、事件绑定每个文件的职责可以概括成一句话index.html 只搭骨架style.css 只管外观questions.js 只声明数据timer.js 只负责计时game.js 持有全部游戏状态。特别注意 questions.js 是纯数据文件里面没有任何函数和运算逻辑这个「数据与逻辑隔离」的设计决定了后续替换题库有多省事。这种把一个游戏中的「数据」「逻辑」「表现」分开的做法其实就是 MVC / MVVM 的雏形。虽然这里没有用任何框架但当你以后切到 Vue 或 React会发现 questions.js 天然对应数据层game.js 里的渲染函数天然对应 View 层迁移时几乎不需要重构思路。2.2 启动方式没有构建步骤的本地运行这个项目没有构建步骤启动方式是最简单的那一种。我一般直接切到项目目录执行一条命令cd guess-game python -m http.server 8080然后在浏览器访问http://localhost:8080就能开始玩。为什么不直接双击 index.html因为部分浏览器对file://协议下加载外部 JS 文件有限制。虽然这个项目没有跨域请求双击大概率也能跑但通过本地 HTTP 服务打开是更稳的做法——后面如果你想加fetch加载远程题库就必须走 HTTP 协议。如果你用 VS Code装个 Live Server 插件右键 index.html 选「Open with Live Server」效果一样。注意端口别和你本地其他服务冲突8080 被占用时换成 8000 或者 3000 都行命令里的端口号跟着改就行。还有一个附带好处同一局域网下手机访问http://电脑IP:8080也能打开这个页面很适合拿去找朋友实测题目难度。2.3 数据层设计题目数组的字段与扩展先看 questions.js 的核心内容const QUESTIONS [ { id: 1, category: 常识, question: 什么东西越洗越脏, answer: 水, hints: [和液体有关, 每天都会用到], score: 10 }, { id: 2, category: 动物, question: 有头无脚走路靠跳的是什么, answer: 袋鼠, hints: [后腿特别有力, 肚子上有口袋], score: 10 }, { id: 3, category: 字谜, question: 山上还有山猜一个字, answer: 出, hints: [两个山字叠起来, 出门的「出」], score: 20 } ];每条题目对象包含六个字段id是唯一标识category用来做分类筛选question是题干answer是标准答案hints是一个字符串数组逐条揭示score是这道题的基础分。把answer存成普通字符串而不是正则表达式是为了让替换题库的人完全不需要懂正则后续在游戏逻辑里统一做标准化比对比在数据层塞各种匹配规则可控得多。hints设计成数组而不是单条字符串是刻意的这样每题可以有多级提示第一级给模糊线索第二级才给接近答案的线索游戏难度梯度就出来了。score字段则是为计分系统预留的你可以给字谜 20 分、常识 10 分难易题分数拉开排名才会有区分度。3. 核心玩法实现出题、答题、判分三段逻辑完整走读3.1 随机出题与洗牌算法游戏开始时要保证每局题目顺序不一样。常见做法是初始化时把 QUESTIONS 拷贝一份再随机打乱这里用经典的 Fisher-Yates 洗牌算法function shuffleArray(arr) { const result [...arr]; // 浅拷贝避免改坏原数组 for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [result[i], result[j]] [result[j], result[i]]; } return result; } function initGame() { gameState { currentIndex: 0, // 当前答到第几题 totalScore: 0, // 累计总分 streak: 0, // 连续答对次数 questionList: shuffleArray(QUESTIONS) }; renderQuestion(gameState.questionList[0], 0, gameState.questionList.length); }两个关键点要特别说明。第一[...arr]浅拷贝很重要如果直接在原数组上打乱QUESTIONS 的原始顺序就永久变了玩家点「再来一局」时重新 initGame 拿到的还是被打乱后的顺序洗牌就失去了意义。第二[result[i], result[j]] [result[j], result[i]]是 ES6 数组解构交换的写法比三步交换更简洁现代浏览器都支持。这里容易踩的坑是有人用arr.sort(() Math.random() - 0.5)来洗牌。这个写法代码更短但随机性分布不均匀sort 的 comparator 每次比较都在变化会导致某些排列出现的概率明显偏高。Fisher-Yates 是 O(n) 复杂度且每个位置被选中的概率均等是正经洗牌该用的默认实现。initGame里四个字段是全局唯一状态源每次开始新游戏都重置确保「再来一局」不会继承上一局的进度。3.2 输入标准化与答案比对答题判断是这类游戏的核心逻辑。直接比较字符串是最直觉的方案但实际跑起来会发现「水 」和「水」在玩家眼里是同一个答案程序里却不相等。所以比对前先做一层标准化处理function normalizeAnswer(text) { return text.trim().replace(/\s/g, ); // 去首尾空格再去所有内部空白 } function checkAnswer(userInput, correctAnswer) { const input normalizeAnswer(userInput); const target normalizeAnswer(correctAnswer); return input target; // 全等比对答案必须精确 }normalizeAnswer做了两件事trim()去掉首尾空格replace(/\s/g, )把字符串里所有连续空白字符去掉。\s匹配空白表示至少一个g是全局替换标志。这样用户输入「水 」、输入法切出来的全角空格或者中间不小心多敲的空格都能正确匹配标准答案。这里没有用includes()做模糊匹配原因很直接答案必须是精确的。「山上还有山」如果输入「山」也算对游戏就失去判断力了计分也没意义。还有一个边界要处理用户什么都不输直接提交。checkAnswer(, 出)返回 false 会走答错分支把 streak 清零更友好的做法是在提交前判断输入为空时直接提示「先输入答案再提交」这个判断不复杂但能避免玩家误按回车白白断掉连击。3.3 计分规则时间奖励与连击机制判定正确后要算分。单纯按每题固定分数累计太单调这里加了两层激励剩余时间奖励和连击奖励。function calculateScore(baseScore, timeLeft, streak) { let score baseScore timeLeft; // 剩余时间折算成分数 if (streak 0) { score streak * 2; // 连击额外加分 } return score; } function handleCorrect() { gameState.streak; const gained calculateScore( gameState.questionList[gameState.currentIndex].score, timer.getLeft(), gameState.streak ); gameState.totalScore gained; scoreEl.textContent 得分 gameState.totalScore; }三个参数的含义baseScore是题目在 questions.js 里配置的基础分timeLeft是回答时倒计时的剩余秒数streak是当前连续答对次数。剩余时间越多得分越高等于变相奖励手速连击次数越多额外分越高连续答对三题时第三题光连击奖励就有 6 分。同样的题库能玩出不同分数高手和平民的差距主要体现在时间奖励和连击累积上。handleCorrect先递增streak再调calculateScore累加总分随后直接更新页面分数节点。注意timer.getLeft()必须在计时器还没归零时调用如果题目超时被自动判错根本走不到这个分支。分数节点用textContent更新而不是innerHTML这里涉及到文本注入安全下一章展开说。4. 交互与渲染原生 DOM 操作和事件绑定的实战细节渲染和事件绑定是这项目里和 DOM 走得最近的部分。状态管理、判分逻辑最终都要反馈到页面元素上把所有textContent赋值和监听器集中到独立的渲染函数里处理可以避免 DOM 操作散落在各个逻辑函数中不然改版时就得满文件找补丁。4.1 渲染函数textContent 优先与输入框聚焦题目信息要实时反映到页面渲染函数把「改状态」和「画界面」分开管理function renderQuestion(question, index, total) { questionEl.textContent question.question; scoreEl.textContent 得分 gameState.totalScore; countEl.textContent 第 (index 1) / total 题; inputEl.value ; hintBoxEl.textContent ; hintBtn.style.display question.hints.length 0 ? inline-block : none; inputEl.focus(); }这里统一用textContent而不是innerHTML是有讲究的。textContent只设置纯文本浏览器不会把内容解析成 HTML如果题目数据里混入了img onerror...这类字符串用innerHTML就会触发脚本执行这就是常说的 XSS 注入。虽然这个项目的题库是本地静态数据但这个习惯能帮你以后做用户生成内容时少踩一个隐蔽的坑。末尾的inputEl.focus()值得单独说。每次渲染完题目都把焦点放回输入框玩家答完一题后可以直接敲键盘输入下题答案不需要再拿鼠标点一下输入框。这是小游戏体验里容易被忽略的细节有没有这一行连续答题的流畅度差别非常明显。同理还有hintBoxEl.textContent 每换一题先清掉上一题的提示残留。4.2 事件绑定form 提交与答题判定的衔接答题入口用 form 包起来好处是回车天然触发提交不需要额外监听 keydown。事件绑定代码如下gameFormEl.addEventListener(submit, function (event) { event.preventDefault(); // 阻止页面刷新 const value inputEl.value; if (value.trim() ) { hintBoxEl.textContent 先输入答案再提交; return; } if (checkAnswer(value, currentQuestion.answer)) { handleCorrect(); nextQuestion(); } else { gameState.streak 0; // 答错连击清零 hintBoxEl.textContent 再想想答案不对; } });event.preventDefault()是第一行必写的。form 的默认行为是提交并刷新页面不阻止的话每次点提交或按回车整页就刷新了游戏状态全部丢失属于「功能完全没法用」级别的 bug。用 form 而不是 button click 的另一个好处是移动端软键盘的回车键也能直接提交体验更统一。答错时streak归零提示文案直接写到hintBoxEl节点上。这里没用弹窗因为window.alert会阻塞主线程答题节奏被打断后体验很差。实际项目中能用页面内提示就尽量不用弹窗这是通用原则。nextQuestion()负责推进题目索引答完最后一题就切到结算界面function nextQuestion() { gameState.currentIndex; if (gameState.currentIndex gameState.questionList.length) { showResultScreen(); // 全部答完显示结算页 return; } renderQuestion( gameState.questionList[gameState.currentIndex], gameState.currentIndex, gameState.questionList.length ); timer.start(30, updateTimerDisplay, () handleTimeout()); }这段连接了前面所有模块索引递增、判结束、重新渲染、重启计时器。timer.start的三个参数分别是总时长、每秒回调、时间到回调把「计时」和「计时到点了要干什么」彻底切开。4.3 倒计时模块setInterval 的清理陷阱与回调设计计时器单独抽了一个 Timer 模块核心逻辑如下const Timer { timerId: null, left: 0, start(duration, onTick, onEnd) { clearInterval(this.timerId); // 启动前先清旧定时器 this.left duration; this.timerId setInterval(() { this.left--; onTick(this.left); if (this.left 0) { clearInterval(this.timerId); onEnd(); } }, 1000); }, getLeft() { return this.left; } };start方法第一行就调用clearInterval(this.timerId)这是整个模块最关键的防错逻辑。setInterval 不会自动去重如果上一题的计时器没被清掉下一题又start一次就会出现两个计时器同时递减left页面上倒计时看起来「越跑越快」。每次启动前先清掉旧定时器保证任意时刻只有一个计时器在运行。onTick和onEnd是两个回调参数每过一秒onTick收到剩余秒数用于更新页面数字归零时先clearInterval再调onEnd由外层决定跳题或结束游戏。把「计时行为」和「到时行为」分离是这个模块能独立复用的原因。后面你想把单题限时改成整局限时只需要换外层回调Timer 一行都不用动。5. 常见问题排查运行期黑匣子的五个典型翻车现场这个项目结构简单但新手跑起来还是会遇到几个高频问题。按「现象 → 原因 → 解决」列五条基本覆盖最常见的报错和异常表现。5.1 题目乱码UTF-8 编码声明缺失现象打开页面后题目和按钮文字全是「鍑洪」「鐨勫」之类的乱码一眼就能看出来不对。原因基本是两种一是 HTML 文件头部没写meta charsetUTF-8浏览器用了系统默认编码去解析 UTF-8 文件二是编辑器保存文件时用了非 UTF-8 编码。解决在 index.html 的head里补上meta charsetUTF-8然后在编辑器右下角把文件编码切换成 UTF-8 重新保存。VS Code 的操作是点右下角状态栏编码按钮选「Save with Encoding → UTF-8」。这两步缺一不可只改 HTML 不重新保存文件问题依旧。5.2 questions is not defined脚本加载顺序倒挂现象打开页面后 F12 控制台报Uncaught ReferenceError: questions is not defined。原因是 script 标签加载顺序错了game.js 里引用了 questions.js 声明的QUESTIONS但 questions.js 的script标签写在了 game.js 之后浏览器按顺序执行game.js 先跑就引用了一个尚未声明的变量。解决把script srcjs/questions.js放到 game.js 前面。更省心的做法是给所有 script 加defer属性defer 会保证脚本按顺序在文档解析完成后执行不再依赖标签前后的物理位置。5.3 倒计时越跑越快setInterval 叠加未清理现象答题时倒计时像按了快进一秒跳好几秒剩余时间瞬间见底。原因是计时器没有去重每题都调Timer.start()但上一题的定时器没被清掉多个 setInterval 叠加每个都在递减left数字自然成倍往下掉。解决在start()方法第一行强制clearInterval(this.timerId)启动新计时器前清掉旧的。这行要作为固定写法记下来setInterval 和 setTimeout 只要是动态创建的销毁逻辑必须在创建入口同步处理这是前端定时器血泪经验的通用结论。5.4 正确答案被判错空格标准化没做现象玩家输入「水 」或「 水 」这类带空格的答案明明是对的却提示「再想想」。原因是字符串全等比较不会忽略空格中文输入法切换时很容易带出全角或半角空格这在中文猜谜场景里出现概率非常高。解决比对前统一走normalizeAnswer()trim()去首尾空格再正则去掉内部空白。以后把游戏扩展到英文题目还需要补toLowerCase()处理大小写当前纯中文题目不需要。5.5 刷新丢进度游戏状态没持久化现象刷新浏览器后分数、题目进度全部回到初始状态。这不是 bug是状态存储方式决定的gameState存在内存里页面刷新即销毁。如果希望玩家关掉浏览器再打开还能看到历史最高分就得把关键数据持久化到localStorage。解决每次handleCorrect更新总分时把最高分写入localStorage下次initGame时读出来显示。这个改动量很小下一章会给出具体代码。6. 二次开发改造加难度、加本地最高分、写题库校验脚本这个项目的改造空间比想象中大因为数据层和逻辑层分得清楚。我实际做过一轮改造只动了很少的代码效果却很明显分享一个最实用的组合加难度筛选、接入 localStorage 最高分、写题库校验脚本。6.1 难度筛选与 localStorage 最高分先在 questions.js 里给每条数据加difficulty字段取值easy / medium / hard。然后改initGame让它接收难度参数只从符合条件的题目里洗牌function initGame(level) { const pool QUESTIONS.filter(q q.difficulty level); gameState { currentIndex: 0, totalScore: 0, streak: 0, questionList: shuffleArray(pool) }; renderQuestion(gameState.questionList[0], 0, pool.length); }这一步只是把QUESTIONS换成filter之后的pool原来的渲染、判分、计时逻辑完全不用动。这就是数据与逻辑分离的直接收益你改的是上游数据下游逻辑无感知。localStorage 最高分只加两行代码const best Number(localStorage.getItem(guess_best) || 0); if (gameState.totalScore best) { localStorage.setItem(guess_best, String(gameState.totalScore)); }localStorage只能存字符串数字要先String()转成字符串再写入读出来要Number()转回数字这个类型转换是持久化操作最常见的翻车点别漏。6.2 拉题库校验脚本与回归验证题目量大了以后漏写answer、hints写成空数组这类问题肉眼很难查。我习惯在改完题库后跑一遍 Node 脚本做静态校验const fs require(fs); const source fs.readFileSync(./js/questions.js, utf-8); const getQuestions new Function(source \nreturn QUESTIONS;); const list getQuestions(); const errors list.filter(q !q.question || !q.answer || !q.difficulty); console.log(errors.length ? errors : 题库校验通过);用node check.js执行几秒就能把全部题目过一遍。这种new Function方式只在本地校验用数据是你自己的静态文件不涉及用户输入风险可控但别把这种写法带到线上业务代码里。从那以后我每次改完题库结构都会强制把校验脚本跑一遍再打开页面完整点三轮对局确认计分、计时、题目切换都没有异常。这个小习惯帮我挡掉不少低级错误希望帮到你。本文还有配套的精品资源点击获取