中秋知识竞赛HTML5游戏源码解析:数据驱动与状态机实战

中秋知识竞赛HTML5游戏源码解析:数据驱动与状态机实战 简介这是一套以中秋知识竞赛为主题的HTML5游戏源码适合前端初学者、游戏开发爱好者以及需要搭建节日答题页面的开发者。源码完整展示了Canvas动态绘制、Web Storage进度记录、Audio背景音乐控制、事件监听与题目切换等核心API帮助读者理解HTML5游戏从界面渲染到交互反馈的完整链路。压缩包共90个文件大小8.8MB以77张PNG图片承载题干、选项与正误状态配合5张JPG背景图、1个MP3音频、1个HTML入口和3个JS逻辑脚本另外附带的Python脚本可辅助格式化代码。目前已有176人浏览学习。通过分析index.html与main.js等文件可以掌握图片预加载、答题判定、得分统计、音效播放等功能的组织方式资源同时强调服务器环境部署适合结合HTTP服务理解跨域、静态资源托管等实践细节稍作改造即可复用到不同主题的知识竞赛场景。1. 为什么一个中秋问答游戏的源码值得拆开看前端圈子里聊到 HTML5 游戏默认想到的是超级玛丽复刻、Flappy Bird 原版仿写或者像素风的跑酷 Demo。这类项目确实能练 Canvas 手感但如果你是拿它当期末作业、前端面试作品集或者公司中秋活动要一个能当场扫二维码就玩的小游戏你会发现问答类游戏反而更合适没有复杂的物理碰撞不需要逐帧绘制角色动画所有交互都能用 DOM 与状态机解决却能完整覆盖“数据组织、渲染、状态管理、音效反馈、多端适配”这条链路。“中秋知识竞赛HTML5游戏源码”要解决的核心问题归结起来只有两件事第一题目怎么存、怎么抽、怎么保证一轮内不重复第二答题、反馈、跳下一题、结算这个过程怎么用一个干净的循环驱动起来。这两个问题一旦用代码立住了视觉上再叠加节日氛围整体效果就超过很多临时堆 UI 的页面。这篇文章会沿一条实际可复现的开发路径走先定义题库结构再写渲染与状态机然后做动效和音效最后落到单文件部署和性能细节。每段代码都可以直接复制改参数适合新手照着敲也适合有经验的开发者拿去改造成自己的答题框架。2. 题库结构与数据驱动别把题目写死在按钮上2.1 中秋知识题库的数据模型与 JSON 组织方式问答游戏最容易做坏的一点就是把题目和选项直接写在 HTML 的按钮里。一题改文案要翻半天代码想换一套题就得重写页面。常见做法是把题库设计成纯数据数组每个题作为一个对象字段保持统一游戏运行时只读数据、不碰硬编码。const questionBank [ { id: 1, difficulty: 1, theme: 节日由来, question: 中秋节在农历的哪一天, options: [七月初七, 八月十五, 九月初九, 正月十五], answer: 1, explanation: 中秋节定于农历八月十五正值三秋之中故而得名。 }, { id: 2, difficulty: 2, theme: 饮食文化, question: 传统月饼中按地域口味常分为哪两大流派, options: [京式与川式, 广式与苏式, 鲁式与粤式, 闽式与徽式], answer: 1, explanation: 广式月饼皮薄馅厚苏式月饼酥皮层层分明是最有代表性的两派。 }, { id: 3, difficulty: 3, theme: 诗词名句, question: “但愿人长久千里共婵娟”出自哪位词人, options: [李白, 杜甫, 苏轼, 辛弃疾], answer: 2, explanation: 这句词出自苏轼的《水调歌头·明月几时有》写于中秋夜。 } ];这里answer存的是正确选项在options数组中的索引从 0 开始计数而不是直接存文案。这样做的原因是当题目需要随机打乱选项顺序时只要同时重排options并更新索引即可不需要做字符串比较。difficulty字段为 1 到 3后续可以按难度分层抽题避免竞赛里前几题太难、后几题太简单。theme字段用来给题目归类方便做知识维度的得分统计。实际项目中题库量通常在 30 题以上如果把大数组直接写在app.js里维护还算方便但如果你的源码要发给别人二次使用我会更推荐把题库放到独立文件questions.js在script标签里先于主逻辑加载。这样使用者替换题库时只动数据文件不会误伤游戏逻辑。2.2 随机抽题与不重复出题的洗牌算法竞赛场景里每轮游戏应该从题库中随机抽取固定数量的题目比如 10 题并保证这 10 题不重复。最直接的办法是把整份题库复制一份然后做 Fisher-Yates 洗牌再截取前 N 个。function shuffle(source) { const arr [...source]; 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 pickQuestions(count 10) { if (count questionBank.length) { throw new Error(题库数量不足需 ${count} 题实际只有 ${questionBank.length} 题); } return shuffle(questionBank).slice(0, count); }洗牌时不能直接操作原数组先const arr [...source]做浅拷贝避免污染题库的原始顺序。Math.floor(Math.random() * (i 1))保证每次随机位置都在未处理的范围内这是 Fisher-Yates 与朴素sort(() Math.random() - 0.5)的本质区别后者分布不均匀且某些浏览器引擎下还会出现明显的顺序偏好。如果你希望每轮题目难度分布合理可以在抽题前按difficulty分组从 1 分题中抽 4 题、2 分题中抽 4 题、3 分题中抽 2 题再合并打散具体权重按活动时长自行调整。2.3 按主题统计答题正确率把数据读回界面题目带theme字段还有一个用处结算页不能只显示总分还要告诉玩家“你在哪类知识上失分最多”。这一步不需要额外存储答题时做一个主题计分映射即可。const themeStats {}; function recordAnswer(question, isCorrect) { const key question.theme; if (!themeStats[key]) { themeStats[key] { total: 0, correct: 0 }; } themeStats[key].total 1; if (isCorrect) { themeStats[key].correct 1; } }这个对象在每轮游戏开始时重置遍历themeStats就可以渲染出柱状图或百分比列表。这部分逻辑放在数据层视图层只负责读取后续如果要加“答错题目的解析弹窗”也只需要在记录时把question.explanation存进一个错误集合数据来源完全统一。3. 渲染层与状态机把游戏流程从按钮堆叠里解放出来3.1 定义游戏阶段start、quizing、feedback、result问答页面最常见的失控点是大量按钮各自监听事件然后互相修改对方的display属性。比如点完“开始”按钮要手动藏掉首页、显示题目页点完选项又要把题目的按钮改成下一题的状态。一旦流程里插入动画或延时状态就乱成一团。我把整个页面看作一个状态机任何时候只存在一个当前状态界面根据状态渲染事件只做状态切换const GameState { START: START, QUIZING: QUIZING, FEEDBACK: FEEDBACK, RESULT: RESULT }; let state GameState.START; let currentIndex 0; let score 0; let questions [];START展示活动标题、玩法说明、开始按钮。QUIZING展示题目与选项点击选项后立即切换到 FEEDBACK。FEEDBACK短暂显示正确/错误反馈与解析约 1.5 秒后自动进入下一题或 RESULT。RESULT展示总分、答题用时、主题统计提供再来一次按钮。设置一个全局setState(nextState)函数统一负责状态变更和渲染调用。这样代码里不会出现“直接在点击回调里操作 DOM”的散弹式写法每个事件只声明“我要去哪个状态”剩下的交给渲染层。3.2 用模板字符串渲染题库并绑定选项事件渲染层我倾向于直接用模板字符串生成 HTML而不是document.createElement逐节点拼接。前者代码短、可读性好适合题目数量不大、重渲染频率不高的场景。答题区渲染函数如下function renderQuestion() { const question questions[currentIndex]; const current document.querySelector(#questionText); const optionsWrap document.querySelector(#optionsWrap); const progress document.querySelector(#progress); current.textContent 第 ${currentIndex 1} / ${questions.length} 题${question.question}; progress.style.width ((currentIndex 1) / questions.length) * 100 %; optionsWrap.innerHTML question.options.map((opt, idx) button classoption-btn>optionsWrap.addEventListener(click, (e) { const btn e.target.closest(.option-btn); if (!btn || state ! GameState.QUIZING) return; const selected Number(btn.dataset.optionIndex); handleAnswer(selected); });需要注意的是dataset取到的值是字符串必须用Number()转回数字才能和question.answer做严格相等判断。点击后要先判断state区分状态的作用在这里就体现出来了也能防止 FEEDBACK 阶段用户二次点击重复计分。3.3 反馈期延时控制与计分的一次性保护进入 FEEDBACK 后要做三件事标记正确答案、标出用户选错的那个选项、显示解析。正确选项的按钮上加is-correct类错误选项加is-wrong类。关键点在于计时器的管理与计分的幂等性let feedbackTimer null; function handleAnswer(selected) { const question questions[currentIndex]; const isCorrect selected question.answer; if (isCorrect) { score question.difficulty * 10; // 难度1得10分难度3得30分 } recordAnswer(question, isCorrect); state GameState.FEEDBACK; renderFeedback(selected, isCorrect, question); clearTimeout(feedbackTimer); feedbackTimer setTimeout(() { currentIndex 1; state currentIndex questions.length ? GameState.RESULT : GameState.QUIZING; render(); }, 1800); }clearTimeout放在setTimeout之前是一个容易被忽略的防御性写法。如果用户因为某种原因在极短时间内触发两次handleAnswer比如双击事件还没有被state挡住旧的计时器已经被清理不会出现“一秒后连跳两题”的幽灵切换。计分公式里乘以difficulty是为了让难题值得更高的分这是在 2.2 节引入难度字段的收益。renderFeedback阶段不用重新渲染整张题目卡只通过类名切换视觉状态避免闪烁。4. 节日氛围视觉与动效用 CSS 和 Web Audio 把答题体验做成“中秋味”4.1 纯 CSS 绘制月空背景与兔子光影中秋主题不一定要用图片素材CSS 渐变和阴影足够做出有辨识度的夜景页面。月亮的做法是用一个圆形容器的box-shadow模拟月光晕染背景用径向渐变模拟夜空与云层body { background: radial-gradient(circle at 70% 20%, #1a2a4a 0%, #0b1020 70%); min-height: 100vh; margin: 0; font-family: Noto Serif SC, Microsoft YaHei, serif; } .moon { width: 120px; height: 120px; border-radius: 50%; background: radial-gradient(circle at 40% 40%, #fffde7, #ffecb3); box-shadow: 0 0 80px 30px rgba(255, 235, 150, 0.4); position: fixed; top: 60px; right: 15%; z-index: 0; }这里的box-shadow扩散半径用的是80px 30px光线柔和不会像filter: blur()那样造成图层重绘面积暴增。兔子的剪影可以用两个伪元素叠加一个椭圆做身体、两个细长矩形做耳朵颜色沿用月亮的高光色系。作为源码中的动效亮点让云层缓慢横向飘动是性价比最高的做法。给云添加一个无限制的线性动画配合will-change: transform提示浏览器做合成层优化.cloud { position: fixed; background: rgba(255, 255, 255, 0.06); border-radius: 50%; animation: drift 60s linear infinite; will-change: transform; } keyframes drift { from { transform: translateX(-10vw); } to { transform: translateX(110vw); } }不同尺寸的云设置不同的animation-duration视觉上会出现层次差。注意不要把所有的动画时长都设为同一个值否则云层会像阅兵队列一样整齐地平移反而不自然。4.2 答对答错的粒子微动效与分数浮字视觉反馈要落在选项按钮本身。答对时按钮边框泛金光答错时变为暗红色同时在页面中心浮起一个 30 的分数动画。这里我用一个小型的粒子系统在按钮根部生成 8 个工作微粒每个微粒沿一个方向飞出并淡出。function spawnParticles(originElement, color) { const rect originElement.getBoundingClientRect(); const cx rect.left rect.width / 2; const cy rect.top rect.height / 2; for (let i 0; i 8; i) { const particle document.createElement(div); particle.className particle; particle.style.left cx px; particle.style.top cy px; particle.style.background color; const angle (Math.PI * 2 * i) / 8; const distance 60 Math.random() * 40; document.body.appendChild(particle); particle.animate( [ { transform: translate(-50%, -50%) scale(1), opacity: 1 }, { transform: translate(${Math.cos(angle) * distance - 50}%, ${Math.sin(angle) * distance - 50}%) scale(0), opacity: 0 } ], { duration: 600, easing: cubic-bezier(0.2, 0.8, 0.4, 1) } ).onfinish () particle.remove(); } }这里的Element.animate是 Web Animations API 提供的原生方法不需要引入动画库。粒子终点坐标要同时减掉50%因为粒子本身的宽高是 8pxleft和top指向的是左上角不偏移会让粒子向角落聚拢。onfinish回调里把粒子节点移除避免长时间游戏后 DOM 里残留大量透明节点。4.3 音效方案绕开浏览器自动播放限制的 Web Audio 写法浏览器默认禁止页面在用户交互之前播放有声媒体所以如果你在window.onload里直接new Audio(uri).play()控制台会报一个NotAllowedError。常见做法是在第一次点击“开始”按钮时初始化音频上下文。let audioCtx null; function playTone(frequency, duration 0.15) { if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } if (audioCtx.state suspended) { audioCtx.resume(); } const oscillator audioCtx.createOscillator(); const gainNode audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.type sine; oscillator.frequency.value frequency; gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime duration); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime duration); } function playCorrectSound() { playTone(523.25, 0.15); // C5 setTimeout(() playTone(659.25, 0.15), 120); // E5 setTimeout(() playTone(784.0, 0.2), 240); // G5 }exponentialRampToValueAtTime制造的是一个快速衰减的包络比直接设固定音量听起来更像“叮”的一声而不是“嘟”的电子音。答错音可以用一个短促的低频音比如 220Hz 持续 0.2 秒。要点是必须在用户点击事件的处理函数里完成AudioContext的创建任何异步回调里的resume()都可能被浏览器拦截。5. 调试易错点与升级技巧从能跑到能用的分水岭5.1 移动端常见的三处翻车点与排查顺序把页面部署到活动场地后翻车往往发生在移动设备。下面三件事是按出现频率排序的排查时按顺序过一遍。现象原因修复方案答题按钮点击无反应事件委托绑定在了optionsWrap上而移动端点击高亮样式没有触发click或者viewport缺失导致页面被缩放补meta nameviewport contentwidthdevice-width, initial-scale1.0, viewport-fitcover底部按钮被浏览器工具栏遮挡使用了100vh作为容器高度移动端地址栏收起时 vh 会跳变改用100dvh并预留env(safe-area-inset-bottom)音频不响但无报错AudioContext首次创建发生在异步回调中强制把创建逻辑移到“开始”按钮的同步事件里锁定这三点后纯 HTML5 的问答页在微信内置浏览器、Safari 和 Chrome 上都能有稳定的表现。如果现场网络环境不稳定把全部脚本、CSS、字体内联进一个 HTML 文件是最省心的做法双击即可离线运行。5.2 加一个倒计时压力条让竞赛感再上一个台阶先前的状态机里FEEDBACK 阶段靠setTimeout跳转QUIZING 阶段则是等待用户点击。如果要做计时的知识竞赛在QUIZING下额外启动一个每秒递减的计时器归零时视同答错并自动进入 FEEDBACKlet remainingTime 15; let timerHandle null; function startTimer() { remainingTime 15; updateTimerUI(remainingTime); clearInterval(timerHandle); timerHandle setInterval(() { remainingTime - 1; updateTimerUI(remainingTime); if (remainingTime 0) { clearInterval(timerHandle); handleAnswer(-1); // -1 表示超时交由 handleAnswer 统一处理 } }, 1000); }handleAnswer(-1)的巧妙之处在于超时不需要单独写一套逻辑只需要保证renderFeedback里对selected -1做一个前置分支显示“时间到”并直接标记正确项。这样计分、记录、状态切换全部沿用原有链路。5.3 把成绩结算页做成一键生成长图的分享卡片活动类游戏最容易被传播的瞬间是结算页。在 RESULT 状态渲染完成后把分数板绘制到 Canvas 生成图片提供给长按保存。function generateShareCard(score, total) { const canvas document.createElement(canvas); canvas.width 600; canvas.height 800; const ctx canvas.getContext(2d); const gradient ctx.createLinearGradient(0, 0, 0, 800); gradient.addColorStop(0, #1a2a4a); gradient.addColorStop(1, #0b1020); ctx.fillStyle gradient; ctx.fillRect(0, 0, 600, 800); ctx.fillStyle #ffecb3; ctx.font bold 56px serif; ctx.textAlign center; ctx.fillText(${score}, 300, 340); ctx.font 28px serif; ctx.fillStyle #ffffff; ctx.fillText(总分 / ${total}, 300, 400); return canvas.toDataURL(image/png); }生成图片后在页面里创建img标签并替换结算区用户长按图片即能调用系统菜单保存。注意 Canvas 里的字体渲染依赖客户端已安装字体中文字体建议使用“宋体”、“楷体”这类系统自带字族避免部署到 Windows 之外的设备时出现方框字。本文还有配套的精品资源点击获取