简介:这是一份基于JavaScript开发的互动猜谜游戏完整项目,适合前端初学者及对游戏开发感兴趣的开发者用来练习事件处理、页面元素操作与游戏逻辑设计。资源共8个文件,涵盖层叠样式表、脚本、页面、配置文件说明等多种类型,压缩包整体仅1.12兆字节,结构紧凑便于快速下载学习。项目通过监听用户点击触发游戏,利用随机函数生成谜题,通过条件判断验证答案并实时刷新页面内容,同时可结合浏览器本地存储保存得分或进度,完整呈现了从界面渲染到交互反馈的典型流程。已有138人学习下载,包内还包含代码规范配置与说明文档,能够帮助读者规范编码风格、理解项目目录安排,是巩固前端基础、积累实战经验的一份轻量级参考。
1. 猜谜游戏:原生JavaScript实现的状态驱动小应用
刚拿到这份猜谜游戏源码时,我以为撑死就是对着一组数组做判断的小玩具,实际把代码啃完才发现,它在不到十个文件里把题库、状态机、渲染、持久化四层拆得干干净净。整个游戏没有一个框架依赖,但出题去重、提示扣分、限时计时的完整判定链路,全都能在断网状态下跑通。
游戏玩法很简单——屏幕中央给出一条谜面,你在输入框里填成语,匹配就得分,拿不准可以点提示,每个提示会折损基础分;答错不扣分但会记录次数,一局十题跑完把名字写进本地排行榜。对前端学习者来说,它是一个能直接跑起来的原生JavaScript实战标本;对想二次开发的人来说,它最值得看的反而是那层极简状态机怎么写才不容易在加功能时翻车。下面按固定的顺序来:先讲架构和数据流怎么分层,再逐段拆核心模块的判定逻辑,然后是踩过的坑和运行方式,最后聊怎么在不破坏现有逻辑的前提下顺手加一个新玩法。
2. 架构与数据流:题库、状态与渲染的三层分离
2.1 技术选型:为什么原生JavaScript就够用
这个项目的场景决定了它不需要重型框架。游戏交互集中在单个输入框、几个按钮和一块计分面板上,DOM规模很小,手动操作和直接绑定事件的代码量完全可控。引入Vue或React反而多了一层虚拟DOM调度,题库和状态要包在组件里传递,对这样一个体量的项目属于额外负担。
Vite在这里的角色只是开发服务器和打包器,不参与业务逻辑。它提供的热更新让改完代码后浏览器自动刷新,调试体验比裸写静态页面舒服很多。生产环境跑出来的产物是一堆纯静态文件,扔到任意静态服务器或对象存储上都能跑,不需要Node服务常驻。
模块组织上是标准的ES Module方案,四个js文件按职责切分:data.js只放题库纯数据,game.js持有全部游戏逻辑和状态,ui.js负责DOM读写,storage.js封装本地存储。这样切有一个实际收益——将来想换界面框架时,game.js和storage.js一行都不用动,只重写ui.js。
2.2 目录结构:六个文件承担四条职责
guess-game/ ├── index.html # 唯一页面骨架 ├── package.json # 依赖与npm脚本 ├── vite.config.js # 开发服务器与构建配置 ├── css/ │ └── style.css # 卡片、动画、响应式布局 └── js/ ├── data.js # 成语题库(纯数据) ├── game.js # 游戏状态机与核心规则 ├── ui.js # DOM操作与事件绑定 └── storage.js # localStorage封装index.html里只放根容器和音效元素,所有界面都由ui.js根据状态动态生成。css/style.css用固定宽度的卡片布局,配合min-height保证提示内容增减时页面不跳动。vite.config.js只做了两件小事:开发端口和base: './'——这个配置很关键,能让构建产物在子目录或file协议下都能正确加载资源。
四个js文件的依赖关系是单向的:ui.js依赖game.js暴露的状态和动作,game.js依赖data.js的题库和storage.js的读写方法。谁都不反向依赖,所以单独替换任何一个文件时不会牵连其他模块。
2.3 单向数据流:从用户输入到界面刷新
游戏的状态集中在game.js的模块级state对象里,它是整个应用唯一可信的数据源。每次用户操作——提交答案、使用提示、跳过题目——都会走进game.js的对应函数,函数检查当前状态、计算变更、更新state,然后调用ui.js的渲染函数一次性重绘整个界面。
// game.js 顶部,模块级状态 const state = { mode: 'classic', // 游戏模式,预留扩展位 pool: [], // 待出题池,洗牌后挨个取 currentIndex: -1, // 当前题在pool中的下标 score: 0, // 累计得分 usedTips: 0, // 本题已用提示次数,用于扣分 wrongAttempts: 0, // 本题答错次数,仅记录展示 };state被设计成扁平结构,没有嵌套对象,原因是避免多层引用带来的隐式共享问题。一个搜索题解时最常见的翻车现场就是状态分散在多个变量里,改了一个忘了另一个,界面和数据对不上。所有动作函数都遵循同一套流程:读取当前状态,判断是否允许执行,计算新值,覆盖旧值,触发render。
数据流是肉眼可见的单向循环:用户点按钮 → 事件回调 →game.js的action函数 → 修改state→ui.render(state)→ 重新绘制DOM。想追踪一个bug时,只需要沿着动作函数找它改了哪几个字段,再去渲染函数确认这些字段对应的DOM区域,不用在整个代码库里来回跳。
3. 核心模块拆解:出题、判分与排行榜的完整链路
3.1 题库结构:谜面、答案、难度与提示的字段设计
题库是项目的血液,它的结构决定了判分逻辑能写得多简洁。每条题目只存四样东西:唯一id、难度等级、谜面描述和按顺序排列的提示数组。每个难度档存放约三十条成语,整体规模控制在九十条以上,既保证一局十题不重样,又不至于让数据文件大到影响加载。
// data.js 题库片段 export const questions = [ { id: 1, difficulty: 1, // 1: 简单 2: 中等 3: 困难 clue: "形容做事极其认真,连最细微的地方也不疏忽", answer: "一丝不苟", tips: [ "第一个字是『一』", "常用来夸人批改作业、核对账目很仔细", "最后一个字与『狗』同音" ] }, // 每个难度约 30 题,共 90+ ];id用于跨存储匹配,比如恢复游戏快照时,通过id从全量题库里反查出原题对象。difficulty直接映射基础分,简单题10分、中等题20分、困难题30分。tips是数组而非字符串,因为扣分规则按条数计算,同时UI层需要逐条展示。
这里有个设计取舍值得学习:谜面刻意用描述性文字而不用谜语格式。描述性谜面让答案分布更可控,避免出一道谁都猜不出来的冷谜;提示数组的顺序也决定了难度递增的体验——第一条给最模糊的线索,后面越来越接近答案,每个提示对应一次明确的扣分。
3.2 出题与去重:Fisher-Yates洗牌与待出题池
一局游戏从题库中抽取十题,最怕的是十分钟里遇到三道同一成语。解决方案不复杂:游戏开始时把整个题库拷贝一份,用Fisher-Yates算法原地洗牌,然后按顺序从头取出题,每答完一题就移动指针或从池中移除。这样一轮内不可能出现重复题,除非题库总量本身就少于十题。
function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; } function startGame() { state.pool = shuffle([...questions]); state.currentIndex = 0; state.score = 0; state.usedTips = 0; renderQuestion(); }Math.floor(Math.random() * (i + 1))生成从0到i的随机下标,每次交换当前位置元素与随机位置元素。因为随机数可能等于i,所以元素也有机会留在原地,这是洗牌的正确行为。注意[...questions]浅拷贝了题库——必须拷贝,直接shuffle原数组会污染全局题库,下次开局时已经打乱了,无法回到原始顺序。
currentIndex在每次答对或跳题后加一,当它等于pool.length时说明十题完毕,游戏进入结算。若想实现“答错也继续出同一题”,只需要在答错分支里不移动currentIndex,这个状态机天然支持这种规则。
3.3 判分与扣分规则:一次提交里发生的三次运算
提交答案的处理函数是整条链路的核心,它要同时处理答案匹配、难度分值计算、提示扣分三件事。这里的关键设计是扣分不与基础分做百分比运算后再加总,而是先算基础分再逐条扣,保证规则可解释——用户能明确知道“用了两个提示扣了多少分”。
function checkAnswer(input) { const q = state.pool[state.currentIndex]; if (!q) return null; // 池子已空或状态异常 if (input.trim() !== q.answer) { state.wrongAttempts++; return { ok: false, reason: 'wrong' }; } const base = { 1: 10, 2: 20, 3: 30 }[q.difficulty]; const deduction = Math.round(base * 0.3 * state.usedTips); const gained = Math.max(0, base - deduction); state.score += gained; return { ok: true, gained }; }input.trim()负责清除首尾空格,中文输入法偶尔在确认后残留空格,这一步能避免明明答对了被判定失败。扣分公式的基准是单个提示扣除基础分的30%,三条提示最多扣掉90%,这个比例保证玩家不会因使用提示直接得零分,也不会出现负数。
function useHint() { const q = state.pool[state.currentIndex]; const nextIdx = state.usedTips; // 当前应取第几条 if (nextIdx >= q.tips.length) return null; // 提示耗尽 state.usedTips++; return q.tips[nextIdx]; }useHint返回的是提示字符串而非下标,由ui.js负责挂载到DOM上。这里把usedTips放在全局状态而不是buttons数组的length上,有个实际好处——恢复快照时不需要重新遍历DOM,直接从state里读出数字即可。
3.4 排行榜持久化:localStorage的容错封装
排行榜模块体积最小但最容易踩坑。直接调localStorage.getItem然后JSON.parse,在用户清空站点数据或处于隐私模式时会抛异常,必须包一层try/catch。saveRank负责追加、排序、截断三步操作,把榜单控制在十条以内。
const KEY = 'guess_game_rank'; export function loadRank() { try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch { return []; } } export function saveRank(entry) { const list = loadRank(); list.push(entry); list.sort((a, b) => b.score - a.score); localStorage.setItem(KEY, JSON.stringify(list.slice(0, 10))); }entry的结构由调用方拼好,包含玩家名、分数和结束时间戳。排序放在存储层而不是UI层,保证不管从哪个入口调用saveRank,拿到的榜单永远是有序的。截断操作放在排序之后而非之前,否则超过十条时新纪录可能被数组越界挤掉。使用时间戳存而不是日期字符串,是为了规避不同浏览器对日期格式解析不一致的问题,这个坑在后面的避坑章节展开讲。
4. 常见问题与避坑:本地调试与持久化踩过的五个坑
4.1 输入法回车导致答案误判
现象:在输入框敲入拼音后按回车确认候选词,答案会瞬间被提交一次,有时候显示“回答错误”但明明还没点提交按钮。
原因:keydown事件在输入法组词状态下也会触发,此时event.isComposing为true,代码没有过滤这个状态,把“确认拼音”当成了“提交答案”。
inputEl.addEventListener('keydown', (e) => { if (e.isComposing) return; // 输入法组词中,忽略一切按键 if (e.key === 'Enter') checkAnswer(); });解决:在回车判断之前检查e.isComposing,为true时直接return。这个坑中文应用几乎必踩,团队里新来的同事第一次接手搜索框逻辑时总会再犯一次。
4.2 刷新一下就白玩:内存状态没有恢复
现象:打了一局四道题、斩获七十分,手一滑按了F5,游戏回到初始状态,分数和当前题号全部清零。
原因:state对象活在内存里,页面刷新后JavaScript重新执行,一切都得从头开始。项目本身支持localStorage持久化,但没有把“游戏进行中的快照”也存进去。
解决:在每次得分或换题时同步一份快照到localStorage,页面加载时先尝试恢复再继续。快照里只存pool中各题的id和currentIndex,页面刷新后用id映射回原题对象,避免把整个题库序列化进存储。
4.3 同一道题一局里出现两次
现象:玩到第五题时遇到了第一题刚答过的成语,让玩家觉得题库有bug。
原因:早期的抽题逻辑用的是Math.random()直接取下标,抽完不标记,同一题会被重复抽中。这种随机不是无放回的,题库越大重复概率越高。
解决:回到第3.2节的洗牌方案。开局时全量洗牌再顺序出题,完全杜绝一轮内的重复。如果以后题库扩充到上千条,可以改成“取前N条后重新洗牌窗口”的策略,平衡重复度和随机感。
4.4 提示条数变化把布局震飞了
现象:点击“使用提示”后卡片内容变高,提交按钮被往下挤,鼠标点下去时已经对不准位置了。
原因:提示区是动态追加DOM节点,外层容器高度没预留余量,内容高度变化后整个卡片FC的布局重新计算。
解决:对提示区和错误提示区设置min-height,固定高度的同时允许内容超出。卡片主体用flex纵向布局,把按钮区域放到flex-shrink: 0的底部,不管中间内容多长,按钮永远在可视区域内。
4.5 排行榜时间显示NaN
现象:排行榜上的日期显示成“NaN/NaN/NaN”,但分数和名字都正常。
原因:存储时用了YYYY-MM-DD HH:mm:ss格式的字符串,渲染时执行new Date('2024-06-01 10:20:30')。部分浏览器引擎不认这种中间带空格的格式,直接返回Invalid Date。
解决:存储一律用Date.now()返回的数字时间戳,展示时再格式化。数字格式不依赖本地化解析规则,跨浏览器、跨平台都不会出现这个玄学问题。
5. 运行与部署:从npm install到生成静态站点
5.1 本地环境:Node版本与依赖清单
项目跑起来只有一个前置依赖:Node.js。推荐使用18及以上版本,包管理器和构建工具都兼容这个基线。仓库没有锁第三方运行时依赖,核心的Vite和原生ES Module都不需要额外原生模块编译,所以安装过程很省心。
node -v npm -v如果node -v输出v18.x或更高,直接进入下一步。低于16的话建议先升级,老版本Node跑Vite会报文件监听相关的错误,那个错误信息不太友好,容易让人误判是项目本身的bug。
5.2 安装与启动:三条命令跑起来
npm install npm run devnpm install会把Vite拉到node_modules里。装完后执行npm run dev,终端会输出一个localhost:5173的地址,浏览器打开就是游戏首页。开发模式下Vite会拦截模块请求并按需转译,所以第一次打开稍慢,之后每次刷新基本是秒开。
验证是否跑通的标准:页面能随机出第一题,输入正确答案后分数从0变成对应分值,排行榜能写入一条记录。走到这一步说明数据层、状态层和渲染层全部正常联通。
5.3 构建部署:dist目录的三种放法
npm run build构建完成后输出dist/目录,里面是压缩后的index.html、css和js。因为vite.config.js里设置了base: './',产物里所有资源路径都是相对路径,这让部署变得非常灵活。
三种常用放法:扔到Nginx的静态站点目录,配一个location指向dist文件夹;放到对象存储的Bucket并开启静态网站托管,按流量计费、无需维护服务器;或者直接压缩dist文件夹发给同事,本地解开用任意静态服务器起服务就能玩。注意不要用浏览器直接双击index.html打开——ES Module在file://协议下会被浏览器安全策略拦截,这是最容易让新手卡壳的地方。
5.4 常见启动问题:端口占用与构建缓存残留
Vite默认端口被占用时,会提示并自动尝试下一个端口,终端日志里有Port 5173 is in use, trying another one...。如果想让端口固定,在vite.config.js里加server: { port: 3000, strictPort: true },strictPort设为true后端口被占会直接报错退出,适合写自动化脚本时用。
另一个隐蔽问题是构建缓存残留。改过data.js等核心文件后,npm run build默认复用node_modules/.vite里的缓存,偶尔会出现“代码明明改了,产物没更新”的错觉。清理方式是把dist和node_modules/.vite删掉重新构建,或者执行npx vite build --force强制刷新缓存。
6. 二次开发:十分钟加一个连击挑战模式
6.1 改造思路与核心代码
原生JavaScript项目加功能的关键是顺着状态机扩展,而不是往事件回调里堆代码。以加一个“限时连击模式”为例:经典模式是一题不限时、答对得分;挑战模式每题限时15秒,倒计时内答对进入下一题,连续答对十次触发额外加分。
先在state里补两个字段mode和combo,再新增一个启动函数和计时器管理函数。计时器句柄必须存到state上,否则模式切换时旧计时器不会被清理,会出现两个计时器同时跑、分数跳变的诡异现象。
function startRush() { state.mode = 'rush'; state.combo = 0; state.currentIndex = 0; clearInterval(state.timer); // 清掉可能残留的旧计时器 state.timer = setInterval(() => { if (state.mode !== 'rush') return; // 模式切换后直接退出 failQuestion(); // 超时判负,自动换题 }, 15000); } function onCorrect(q) { if (state.mode !== 'rush') return; state.combo++; if (state.combo % 10 === 0) { state.score += 30; // 每10连击额外奖励 } }改造时只在checkAnswer的答对分支里多调用一次onCorrect,经典模式的原始流程完全不用动。向外暴露的接口保持兼容,ui.js需要改的只有“挑战模式下显示倒计时”这一个渲染点。
6.2 验证方法
改完不要急着发布,按这四条走一遍回归:经典模式跑完十题,确认原有判分和提示扣分没被连击逻辑干扰;挑战模式连续答对十题,确认额外三十分只加一次;把计时器临时改成两秒,确认超时会自动换题且不会重复触发换题逻辑;在挑战模式中途回到经典模式,确认旧计时器被清除不会出现分数跳加。从那以后我每次给这个游戏加功能,都强制先把状态机跑一遍、把可能留下两个计时器的地方全查一遍再动手写代码,这套习惯帮我避掉了无数DIY改动引发的离奇bug,希望帮到你。
本文还有配套的精品资源,点击获取