
简介一份以中秋为主题的知识竞赛HTML5游戏源码面向刚接触HTML5游戏开发的前端学习者与编程爱好者用于理解游戏交互、题库逻辑和网页多媒体整合。压缩包共90个文件约8.8MB以77个png图片为主体配合jpg背景、js脚本、html入口、py辅助脚本与mp3音频分别承载题目选项、界面素材、游戏逻辑、页面加载及背景音乐目录中可见按题号划分的图片命名与答案匹配关系。已有176人学习下载适合作为课后实战、社团项目或培训参考。源码涉及Canvas绘图、Web Storage本地存储、Audio音频控制、事件监听和Web Workers后台计算可对照学习页面渲染、得分记录、音乐切换与性能优化附带py脚本辅助整理资源方便部署到静态服务器或扩展排行榜、答题倒计时等玩法能帮助开发者从素材组织到逻辑实现获得完整项目经验。1. 中秋知识竞赛 HTML5 游戏源码先想清楚三件事再动手中秋知识竞赛 HTML5 游戏源码拿到手双击 index.html 通常能玩但玩到第三轮就会遇见题库重复、切题卡顿、手机上按钮偏小这类问题。知识竞赛这个品类真正难做的地方不在引擎选型而在题库结构、答题状态机和移动端触感这三件事。下面按一次真实活动页交付的顺序拆开讲先组织题目再写游戏循环然后处理中秋主题的 html5 动画和触屏适配最后给一套发布前能落地的参数覆盖与验证技巧。适合要做企业中秋活动页的前端、拿 HTML5 网页设计作业交差的学生以及想拿现成游戏源码换题库的运营同学。2. 题库先行用 JSON 把中秋知识组织成 HTML5 游戏可消费的语料很多人拿到游戏源码第一件事是改界面样式实际上先搭题库的人改起来最快。知识竞赛的一次对局本质上就是从一个题目池里按规则抽出 N 道题再依次渲染、判分、给解释。题库格式定好了后面所有逻辑都能跟着少改所以这一章先把数据层钉死。2.1 题目字段设计一道题除了题干和答案还要留哪些元信息一道能在知识竞赛里表现完整的题至少包含题干、选项数组、正确答案下标、难度、分类、解析。选项用数组而不是optionA这种散装字段渲染时直接for循环生成按钮省去手写四行绑定的事件代码。我的题库条目一般长这样{ id: astro_001, question: 中秋赏月时太阳、地球、月球三者的位置关系最接近下列哪一项, options: [ 地球在太阳和月球之间, 月球在太阳和地球之间, 太阳在地球和月球之间, 三者排列成任意角度 ], answer: 0, difficulty: 2, category: 天文, explanation: 满月时地球位于太阳与月球之间月球被太阳照亮的半球正对地球。 }字段含义逐一说answer用下标 0 起算因为options数组长度可以动态变化以后想做成四选一或五选一判分逻辑不用改。difficulty用来控制抽题权重category可以做分类筛选explanation在答错后作为知识卡片展示。这一项最容易被砍掉但对活动型页面恰恰是用户愿意分享的理由保留它。题目字段并不是越全越好字段和渲染逻辑要匹配。question和explanation都建议放在同一个对象里避免答题逻辑从两个接口拉数据分类字段在题量超过 30 道之后再考虑要不要做 tab 切换题少的时候做分类反而增加跳转成本。2.1.1 答案为什么存下标而不是文本常见的问题是把answer存成文本“地球在太阳和月球之间”一旦运营改文案判分就失效存下标则对文案不敏感。但下标方式要求题目维护者必须保证options顺序固定否则换了一道题选项顺序变了下标对应的答案就漂了。所以我会在题库校验脚本里顺带检查answer是否超出options的下标范围这个脚本在 2.3 节给出。2.2 随机抽题与难度分层的常见做法知识竞赛要让人愿意多玩几局核心是每局题目组合尽量不重复。常见做法是先把全部题目按difficulty分组组内洗牌再按比例取题最后整体再洗一遍。直接在大池子里随机抽容易出现一局全是难题或者连续十几道同一分类体验很糟糕。function shuffle(arr) { const a arr.slice(); for (let i a.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [a[i], a[j]] [a[j], a[i]]; } return a; } function pickQuestions(pool, quotaByDifficulty) { const picked []; for (const [level, count] of Object.entries(quotaByDifficulty)) { const candidates shuffle(pool.filter((q) q.difficulty Number(level))); if (candidates.length count) { console.warn(难度 ${level} 题目不足期望 ${count}实际 ${candidates.length}); } picked.push(...candidates.slice(0, count)); } return shuffle(picked); }quotaByDifficulty是一个配置对象比如{ 1: 5, 2: 4, 3: 3 }表示简单 5 题、中等 4 题、困难 3 题一局 12 题。选择分组抽取而不是随机抽满 N 道是为了保证对局难度曲线稳定新手不会一上来接连碰到困难题老玩家后期也能撞上难题。这里有一个容易忽略的边界当题库量小于抽取量时代码里的console.warn只是提醒实际只取现有题数页面能跑但局间重复率会变高。按我自己的习惯题库是运营改的 JSON我会保留warn而不是throw否则运营改错一处整个页面白屏排查成本更高。代码里的Number(level)是防御性的因为Object.entries拿到的 key 一定是字符串不转换的话q.difficulty和level会做严格相等比较永远不相等。2.3 用 node 脚本校验题库再用本地静态服务器预览题库文件再小心也容易出低级错误少个逗号、重复 id、answer 越界。常见做法是在项目里放一个校验脚本提交前跑一遍node scripts/validate-quiz.js data/quiz.json --strict脚本内部逻辑大致是读 JSON 文件检查每题 id 唯一性检查 options 长度至少为 2检查 answer 是否为合法下标检查 question 和 explanation 非空。核心校验循环如下const fs require(fs); const quiz JSON.parse(fs.readFileSync(process.argv[2], utf8)); const ids new Set(); for (const q of quiz) { if (ids.has(q.id)) throw new Error(重复 id: ${q.id}); ids.add(q.id); if (!Array.isArray(q.options) || q.options.length 2) { throw new Error(${q.id} options 至少要有 2 项); } if (q.answer 0 || q.answer q.options.length) { throw new Error(${q.id} answer 越界); } } console.log(校验通过共 ${quiz.length} 题);校验脚本有两点要注意一是用Array.isArray而不是typeof q.options object因为数组在typeof下只返回objectnull 也会被误判二是若允许某类娱乐题没有解析就别把explanation塞进必填校验否则后期加题频繁报错脚本会被同事直接删掉。题库就绪后本地预览不要直接用 file:// 双击打开。页面脚本要用fetch加载 JSON 题库file 协议下fetch会被浏览器拦截表现是页面白屏、控制台报 CORS 错误。常见做法是起一个静态服务器任选其一命令适用场景python3 -m http.server 8000本机已有 Python零依赖npx serve -l 8000前端项目Node 环境自动忽略隐藏文件npm run dev项目里已配好 Vite 等开发服务器把题库 JSON 和 index.html 放在同一个静态目录下访问http://localhost:8000就能看到完整游戏效果。这一步看起来多花十秒却能避开一大半“源码打不开”的反馈。3. 在 HTML5 游戏循环里落地答题、倒计时与判分状态机题库是内容游戏循环是骨架。知识竞赛页面看着简单但连点、切后台、倒计时归零这些真实操作会把事件回调搅在一起。把所有行为收拢进状态机是最不容易出错的做法也方便后续加暂停按钮或埋点统计。3.1 用状态机约束用户操作READY、PLAY、RESULT 的流转一个标准知识竞赛页至少有三个状态开始前的 READY、答题中的 PLAY、结束后的 RESULT。要不要 PAUSE取决于游戏是否允许用户切走再回来活动页一般不需要严格暂停但倒计时必须处理切后台的情况这放在 3.2 单独讲。const State { READY: READY, PLAY: PLAY, RESULT: RESULT, }; class QuizGame { constructor({ questionList, root }) { this.list questionList; this.index 0; this.score 0; this.state State.READY; this.bindEvents(root); } transition(next) { this.state next; this.render(); } submit(answerIndex) { if (this.state ! State.PLAY) return; const q this.list[this.index]; // 判分、累计分数、推进题目 } bindEvents(root) { root.addEventListener(click, (e) { const btn e.target.closest([data-option]); if (!btn) return; this.submit(Number(btn.dataset.option)); }); } }transition方法让所有状态变化只走一个入口将来要埋点或者打印调试日志都只改这一处。submit开头的状态守卫是关键用户连点两下答案时第二次进入submit时状态已经切到下一题或 RESULT直接被 return 掉不会出现同一题判两次、分数翻倍的情况。这里有个细节click 事件绑定在root上用事件委托而不是在每个按钮上单独绑监听。因为题目切换会重建按钮 DOM委托写法不用每次解绑销毁内存和代码量都更省。按钮上的>class Countdown { constructor(durationMs, onTick, onDone) { this.duration durationMs; this.endAt 0; this.onTick onTick; this.onDone onDone; } start() { this.endAt Date.now() this.duration; this.raf requestAnimationFrame(this.tick); } pause() { cancelAnimationFrame(this.raf); } tick () { const remain Math.max(0, this.endAt - Date.now()); this.onTick(remain); if (remain 0) { this.onDone(); return; } this.raf requestAnimationFrame(this.tick); }; }tick里先算剩余毫秒再决定继续下一帧还是触发onDoneonDone里调用外层游戏的状态机把状态推走。用 rAF 代替 setInterval 的收益是页面不可见时 rAF 自动停止切回来时按时间戳补算剩余时间不会出现积压回调全部触发一次的尴尬。3.2.1 超时与手动提交走同一判分路径超时处理要和用户主动点击共用一条路径无论自动超时还是手动提交都调用同一个finishAnswer方法。这样计分规则只有一份实现不容易出现两套逻辑打架。常见的坑是在超时回调里直接score 0然后跳题而手动提交里做了连击计算最后同一局两种操作的分数口径不一致。3.3 计分公式基础分、连击加成和时间奖励怎么配计分规则直接决定排行榜的观赏性。基础分固定连击给百分比加成剩余时间再给少量奖励这套组合在活动页里最常见。参数默认值说明baseScore100答对基础分comboBonusRate5%每连击一次提升 5%maxComboBonus50%连击加成上限timeBonusMax30按剩余时间比例换算的奖励分function calcScore({ base, combo, timePercent }) { const comboBonus Math.min(combo, 10) * 0.05; const timeBonus Math.round(Math.max(0, timePercent) * 30); return Math.round(base * (1 comboBonus)) timeBonus; }combo 表示当前连击数第 1 次答对时 combo 已经是 1此时加成 5%。把 combo 上限压在 10对应 50% 加成封顶避免高手一局分数涨到失去比较意义。timePercent是剩余秒数除以单题限时答得越快奖励越高但上限只有 30 分不至于让运气型选手反超稳定型选手太多。这几项参数放在配置对象里而不是直接写死在各处。运营想调整活动难度时改一行配置就能让整局分数曲线变化不必翻代码。如果想做错题惩罚可以在timePercent为 0 时扣分但活动页一般不建议扣成负数用户心理上会直接放弃重开。4. 中秋主题氛围除了 CSS 还要靠 Canvas 动画与移动端适配撑住答题逻辑再稳拿不出中秋氛围活动页也是半成品。这一章处理两件事一是用 CSS 变量把主题色收敛成一套可切换的换肤体系二是用 Canvas 跑轻量动画再顺手把移动端适配做掉。4.1 用 CSS 变量统一中秋主题配色中秋主题不要直接用纯黑做背景深夜蓝更容易和月亮辉光形成层次。把主色收敛成变量后改主题只需要动:root一处。:root { --night: #1c2541; --moon: #f6e7b2; --moon-glow: rgba(246, 231, 178, 0.55); --card-bg: rgba(255, 244, 214, 0.92); --leaf: #c94b4b; } body { background: var(--night); color: var(--moon); } .card { background: var(--card-bg); color: var(--night); }变量命名按用途而不是颜色名称比如--leaf而不是--red这样运营要求把桂花换成青色时只改变量值不用去页面代码里找哪些类在用红色。圆角、阴影、动效时长也可以放进变量后续想复用这套 HTML5 游戏源码做元宵或国庆主题时换一套 CSS 变量就能直接切换。--card-bg用半透明而不是纯白目的是让背景里的月亮辉光透出来。这里要控制透明度太透明会导致文字和背景对比度不足在户外强光下看不清题目建议卡片底色透明度不要低于 0.85。4.2 用 Canvas 画月亮辉光和飘落桂花瓣CSS 动画适合规则的运动比如按钮呼吸、进度条增长但花瓣要沿曲线下落月亮辉光半径要连续变化用 Canvas 画更容易控制。粒子系统看似复杂最小实现只要几十行。const canvas document.getElementById(scene); const ctx canvas.getContext(2d); const petals Array.from({ length: 40 }, () ({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, r: 3 Math.random() * 4, speedY: 0.5 Math.random() * 1.2, drift: 20 Math.random() * 30, phase: Math.random() * Math.PI * 2, })); function drawFrame(t) { ctx.clearRect(0, 0, canvas.width, canvas.height); drawMoon(t); petals.forEach((p) { p.y p.speedY; p.x Math.sin(p.phase t / 800) * 0.4; if (p.y canvas.height) p.y -20; ctx.fillStyle rgba(201, 75, 75, 0.75); ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); }); requestAnimationFrame(drawFrame); } requestAnimationFrame(drawFrame);关键参数是粒子数量、半径和下落速度。drift和phase控制横向漂移speedY控制下落节奏这几个值建议做成配置对象方便在真机上微调。辉光的实现用createRadialGradient内圈亮、外圈透明这种画法比一张 PNG 图片省流量而且半径可以做呼吸效果。4.2.1 devicePixelRatio 下的画布缩放Canvas 尺寸不做缩放高分屏上画面会发虚直接按像素比放大又容易卡。最小可用的做法是canvas.width clientWidth * devicePixelRatio再在 context 上scale一次。注意别把粒子数量也跟着像素翻倍视觉密度应该按实际屏幕宽度单独调。场景粒子数建议低端安卓20只保留月亮辉光花瓣数量减半常规手机40花瓣加辉光帧率稳定在 60桌面浏览器80可加光晕轨迹注意功耗4.3 移动端适配viewport、rem 基准和按钮触控HTML5 游戏大部分流量在手机上适配的第一件事是 meta viewport。meta nameviewport contentwidthdevice-width, initial-scale1, viewport-fitcover配上viewport-fitcover后还要给顶部和底部留出安全区否则刘海屏会把题目顶到状态栏下面。常用做法是给根容器加padding-top: env(safe-area-inset-top)和padding-bottom: env(safe-area-inset-bottom)。字号用 rem 做基准一种常见的换算方式是以设计稿 750 宽为参照根字号设为calc(100vw / 37.5)此时设计稿上 40px 对应页面 2rem。按钮触控上最影响体验的是点击延迟和误触。现代浏览器设置viewport的widthdevice-width后 300ms 延迟基本消失额外在按钮上声明touch-action: manipulation可以去掉双击缩放造成的点击延迟。按钮高度不要小于 44px这是拇指误触概率明显升高的临界值答题选项这种密集按钮尤其要注意。5. 交付前的最后一步题库热更新、分享卡片与 HTML5 游戏源码发布验证前面已经把题库、状态机、视觉效果和适配做完了最后这一步针对“源码要交给运营换题”和“发布前确认没坏”两个真实场景。5.1 用 URL 参数覆盖题库一套 HTML5 游戏源码复用多场活动活动页面运营经常要换题但开发不想为每场活动改一次代码并重新发布。常见做法是 URL 上带一个题库地址参数页面加载时优先从参数指定的 JSON 文件读取。function loadQuiz() { const params new URLSearchParams(location.search); const dataUrl params.get(data); if (dataUrl) { return fetch(dataUrl).then((res) res.json()); } return fetch(./data/quiz.json).then((res) res.json()); }?data./data/mid-autumn-2025.json这种方式对同源部署最省事发布时把题库文件一起放到静态目录即可。跨域时需要在接口或对象存储上配置 CORS 头部file:// 打开页面则一定失败这也是为什么前面坚持要起本地服务器。5.2 结果页分享信息用 Canvas 现画一张分享卡片排行榜结果页最想要的传播动作是分享。不依赖后端的做法是拿 Canvas 现场画一张结果卡再转成图片。const card document.createElement(canvas); card.width 600; card.height 320; const c card.getContext(2d); c.fillStyle #1c2541; c.fillRect(0, 0, 600, 320); c.fillStyle #f6e7b2; c.font bold 32px sans-serif; c.fillText(我答对 ${correct}/${total} 题来挑战, 40, 120); card.toDataURL(image/jpeg, 0.9);带二维码的分享图需要把二维码当成图片先画进 Canvas然后整体导出这个流程和项目解耦纯前端能完成。注意 Canvas 内容若引用了跨域图片导出前图片要设置crossorigin且服务端允许跨域否则toDataURL会抛安全错误。5.3 发布前验证清单与几个高频坑检查项验证方式常见症状中文编码确认 meta charset 与 HTML 文件保存编码一致题库中文变乱码声音播放必须有一次用户点击后再创建 AudioContext首屏自动播放被拦截无音效答题防连点连续快速点击同一选项只记一次分分数翻倍或题号跳两格切后台恢复题目计时中切走 20 秒再回来回来直接判超时或剩余时间不变安全区高度刘海屏真机查看上下边缘按钮被状态栏或底部横条遮挡安卓 WebView 里音频是高频坑Web Audio 要求必须在用户手势后创建常见处理是点击开始按钮时调用一次audioCtx.resume()之后所有音效都在这个上下文里播放。把这份清单放在项目 README 里发版前逐项打勾比临时翻代码可靠。如果活动热度高记得给题库 JSON 加版本参数?v202509更新题库后浏览器会重新拉取避免缓存让玩家一直看到上一版题目。本文还有配套的精品资源点击获取