雪糕盲猜挑战小程序开发拆解:从玩法设计到前后端实现 📅 发布时间:2026/9/7 15:29:28 👁 浏览次数: 雪糕盲猜挑战从玩法设计到小程序开发我带你完整拆解第一关到底怎么破 天气一热冷饮市场就成了大家关注的重点。最近不少同学在群里玩一款叫“你爱吃的雪糕盲猜挑战”的小游戏第一关就让人集体破防明明是自己从小吃到大的雪糕换个包装、遮住品牌名之后居然完全认不出来。作为一名常年折腾前后端项目的技术博主我第一反应不是“这游戏好难”而是“这个玩法如果用代码实现应该怎么设计”。这篇文章就来做一个冷饮雪糕盲猜挑战的完整技术拆解。我会从游戏玩法分析入手带大家用 Python Flask 搭建后端接口用 HTML CSS JavaScript 写一个移动端友好的答题页面再给出完整的示例代码、数据库设计思路和常见问题排查方案。不管你是刚学编程的新手还是准备做一个小游戏练手的开发者这篇文章都适合你。文章最终会交付一个可以本地运行、可扩展的雪糕盲猜挑战项目。你可以把它当作练手项目也可以在此基础上扩展成真正的运营级答题活动。下面我们直接开始。1. 雪糕盲猜挑战到底是一个什么样的玩法“雪糕盲猜挑战”本质上是一个图片识别与记忆匹配类答题游戏。系统展示一张雪糕图片但把品牌标志、包装上的关键文字遮住或者只展示雪糕本体的局部特写让用户从多个选项里选出正确的雪糕名称。第一关破防的原因也很简单我们平时辨认雪糕主要靠包装上的视觉元素一旦遮住文字、模糊包装单看外形和颜色很多雪糕看起来高度相似比如“绿色心情”和“绿豆沙”、“巧乐兹”和“随便”的巧克力脆皮款确实很难区分。从产品角度拆解一个完整的雪糕盲猜挑战包含这些核心模块功能模块说明关卡管理每个关卡包含若干道题目难度递增题目管理每道题包含雪糕图片、正确名称、干扰选项答题逻辑用户选择答案系统判断对错并计分得分与结算统计答对数、准确率、用时排行榜记录用户成绩形成竞争氛围分享与挑战将成绩分享给好友生成专属挑战链接把玩法抽象之后你会发现这其实就是一套标准的“题库 答题 计分”系统。我们完全可以通过前后端分离的方式实现它。2. 技术选型与开发环境准备为了让项目尽量简单易上手我选择的技术栈如下环节技术方案后端Python Flask数据库SQLite轻量零配置前端HTML CSS JavaScriptAPI 调试浏览器直接访问开发工具VS Code 或任意文本编辑器运行环境Python 3.8 及以上版本为什么选择 Flask原因很简单Flask 非常轻量一个文件就能写出完整 API。适合快速实现小游戏后端不需要复杂的配置。和前端联调方便前后端分离开发思路也清晰。为什么不直接用 Vue/React因为这个项目的核心重点是“玩法逻辑 数据接口”用原生 JavaScript 能让新手更清楚地看到请求接口、渲染答案、判断对错的完整过程。等掌握之后再迁移到 Vue 也很容易。版本方面需要根据你的实际环境调整本文示例以 Python 3.10 环境为准依赖库使用 Flask 2.x 版本。如果你的环境版本不同只要 Flask 能正常启动即可不影响整体代码思路。3. 核心设计数据库模型与接口定义在写代码之前我们先想清楚数据结构和接口设计。这个环节决定了后面代码的清晰程度。3.1 数据库模型设计我们使用 SQLite 数据库只需要建两张表一张是题目表questions一张是用户答题记录表answers。题目表字段设计如下字段名类型说明idINTEGER主键levelINTEGER关卡编号第一关为 1image_urlTEXT雪糕图片地址optionsTEXT四个选项用 JSON 形式存储correct_indexINTEGER正确选项的下标scoreINTEGER本题得分为什么 options 用 JSON 存储因为每个雪糕的选项数量固定为四个用 JSON 数组可以避免额外建一张选项表代码也更好维护。如果后续要做复杂选项逻辑再拆表也不迟。用户答题记录表字段名类型说明idINTEGER主键usernameTEXT用户名levelINTEGER完成关卡correct_countINTEGER答对题数total_countINTEGER总题数scoreINTEGER总得分created_atTEXT答题时间3.2 接口定义后端只需要提供三个接口方法路径功能GET/api/question/获取指定关卡的题目列表POST/api/submit提交答题结果GET/api/ranking获取排行榜这三个接口已经能覆盖“取题、答题、排名”的完整流程。4. 后端接口实现下面我们编写后端代码。首先创建项目目录结构我的目录规划如下snow-ice-blind-challenge/ ├── app.py # Flask 主程序 ├── models.py # 数据库模型 ├── init_db.py # 初始化数据库脚本 ├── requirements.txt # 依赖依赖 ├── static/ │ ├── css/ │ │ └── style.css │ └── js/ │ └── game.js ├── templates/ │ └── index.html └── data/ └── game.db # SQLite 数据库文件运行后生成4.1 创建依赖文件在项目根目录下创建requirements.txt内容如下Flask2.04.2 初始化数据库新建models.py负责数据库连接和建表操作# 文件路径models.py import sqlite3 import os DB_PATH os.path.join(os.path.dirname(__file__), data, game.db) def get_db(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return conn def init_db(): os.makedirs(os.path.dirname(DB_PATH), exist_okTrue) conn get_db() cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, level INTEGER NOT NULL, image_url TEXT NOT NULL, options TEXT NOT NULL, correct_index INTEGER NOT NULL, score INTEGER DEFAULT 1 ) ) cursor.execute( CREATE TABLE IF NOT EXISTS answers ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, level INTEGER NOT NULL, correct_count INTEGER DEFAULT 0, total_count INTEGER DEFAULT 0, score INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ) conn.commit() conn.close() if __name__ __main__: init_db() print(数据库初始化完成)这里有一个容易被忽略的细节data目录可能不存在所以在初始化时需要用os.makedirs(..., exist_okTrue)自动创建目录否则 SQLite 连接时会报“unable to open database file”。4.3 编写 Flask 主程序接下来编写app.py它包含所有 API 接口。为了让代码结构清晰我直接使用 Flask 的蓝图也没有必要项目不大单文件完全够用。# 文件路径app.py import json from flask import Flask, jsonify, request, render_template from models import get_db, init_db, DB_PATH app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/api/question/int:level, methods[GET]) def get_questions(level): db get_db() result db.execute( SELECT * FROM questions WHERE level ? ORDER BY id, (level,) ).fetchall() db.close() if not result: return jsonify({code: 404, msg: 该关卡暂无题目}) questions [] for row in result: questions.append({ id: row[id], image_url: row[image_url], options: json.loads(row[options]), correct_index: row[correct_index], score: row[score] }) return jsonify({code: 0, data: questions}) app.route(/api/submit, methods[POST]) def submit_answer(): data request.get_json() username data.get(username, ).strip() if not username: return jsonify({code: 400, msg: 用户名不能为空}) level data.get(level) answers data.get(answers, []) if not isinstance(level, int) or not isinstance(answers, list): return jsonify({code: 400, msg: 参数格式错误}) db get_db() correct_count 0 total_score 0 for answer in answers: question_id answer.get(question_id) selected_index answer.get(selected_index) row db.execute( SELECT correct_index, score FROM questions WHERE id ?, (question_id,) ).fetchone() if row is None: continue if selected_index row[correct_index]: correct_count 1 total_score row[score] total_count len(answers) db.execute( INSERT INTO answers (username, level, correct_count, total_count, score) VALUES (?, ?, ?, ?, ?) , (username, level, correct_count, total_count, total_score) ) db.commit() db.close() return jsonify({ code: 0, data: { correct_count: correct_count, total_count: total_count, score: total_score } }) app.route(/api/ranking, methods[GET]) def ranking(): db get_db() result db.execute( SELECT username, level, score, correct_count, total_count, created_at FROM answers ORDER BY score DESC, correct_count DESC LIMIT 20 ).fetchall() db.close() ranking_list [] for i, row in enumerate(result, start1): ranking_list.append({ rank: i, username: row[username], level: row[level], score: row[score], correct_count: row[correct_count], total_count: row[total_count], created_at: row[created_at] }) return jsonify({code: 0, data: ranking_list}) if __name__ __main__: init_db() print(数据库文件位置, DB_PATH) app.run(debugTrue)4.4 写入测试题目数据题目数据在开发阶段可以手动插入。我这里提供一个init_db.py增强版在初始化表格的同时插入第一关的示例数据。# 文件路径init_db.py import json from models import get_db, init_db def insert_demo_data(): db get_db() cursor db.cursor() # 检查是否已有数据 count cursor.execute(SELECT COUNT(*) FROM questions).fetchone()[0] if count 0: print(题目数据已存在跳过插入) db.close() return questions [ { level: 1, image_url: /static/images/ice_cream_01.jpg, options: [绿色心情, 绿豆沙, 糯米糍, 冰工厂], correct_index: 0, score: 10 }, { level: 1, image_url: /static/images/ice_cream_02.jpg, options: [巧乐兹, 随便, 苦咖啡, 四个圈], correct_index: 1, score: 10 }, { level: 1, image_url: /static/images/ice_cream_03.jpg, options: [老冰棍, 东北大板, 小布丁, 三色杯], correct_index: 2, score: 10 } ] for q in questions: cursor.execute( INSERT INTO questions (level, image_url, options, correct_index, score) VALUES (?, ?, ?, ?, ?) , (q[level], q[image_url], json.dumps(q[options], ensure_asciiFalse), q[correct_index], q[score]) ) db.commit() db.close() print(插入 3 道关卡题目成功) if __name__ __main__: init_db() insert_demo_data()这里有一个容易踩的坑SQLite 写入 JSON 字符串时如果直接使用str()转换 Python 的 list中文可能会出现问题。正确做法是使用json.dumps(..., ensure_asciiFalse)这样保存到数据库后中文不会变成\u转义序列。5. 前端页面设计与答题逻辑后端接口完成后我们来写前端。前端页面承担两个职责展示题目图片和选项、收集用户的答案并提交。5.1 页面基础结构创建templates/index.html文件!-- 文件路径templates/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title雪糕盲猜挑战/title link relstylesheet href/static/css/style.css /head body div classcontainer h1 雪糕盲猜挑战/h1 p classsubtitle第一关 · 基础篇/p div idgame-area div classuser-input input typetext idusername placeholder输入你的昵称 button idstart-btn onclickstartGame()开始挑战/button /div div idquestion-area styledisplay: none; div classprogress 第 span idcurrent-index1/span / span idtotal-count0/span 题 /div div idimage-box img idquestion-image src alt雪糕图片 /div div idoptions-box classoptions-grid/div div idquestion-result/div /div div idresult-area styledisplay: none;/div /div /div script src/static/js/game.js/script /body /html前端使用styledisplay: none;控制区块显示这是一种简单直接的方式适合小游戏页面。5.2 核心答题逻辑创建static/js/game.js这是整个前端最核心的部分。// 文件路径static/js/game.js let currentLevel 1; let questionList []; let currentIndex 0; let answers []; let username ; async function startGame() { username document.getElementById(username).value.trim(); if (!username) { alert(请先输入昵称); return; } try { const resp await fetch(/api/question/${currentLevel}); const data await resp.json(); if (data.code ! 0) { alert(data.msg || 获取题目失败); return; } questionList data.data; currentIndex 0; answers []; if (questionList.length 0) { alert(该关卡暂无题目); return; } document.querySelector(.user-input).style.display none; document.getElementById(question-area).style.display block; document.getElementById(result-area).style.display none; renderQuestion(); } catch (err) { console.error(err); alert(网络请求异常请确认后端服务已启动); } } function renderQuestion() { const question questionList[currentIndex]; document.getElementById(current-index).textContent currentIndex 1; document.getElementById(total-count).textContent questionList.length; document.getElementById(question-image).src question.image_url; document.getElementById(question-result).textContent ; const optionsBox document.getElementById(options-box); optionsBox.innerHTML ; question.options.forEach((option, idx) { const btn document.createElement(div); btn.className option-item; btn.textContent option; btn.dataset.index idx; btn.onclick () selectOption(idx, btn); optionsBox.appendChild(btn); }); } function selectOption(selectedIndex, el) { const question questionList[currentIndex]; // 记录答案 answers.push({ question_id: question.id, selected_index: selectedIndex }); // 判断对错 const isCorrect selectedIndex question.correct_index; const resultBox document.getElementById(question-result); if (isCorrect) { el.classList.add(correct); resultBox.textContent ✅ 回答正确; } else { el.classList.add(wrong); const options document.querySelectorAll(.option-item); options[question.correct_index].classList.add(correct); resultBox.textContent ❌ 回答错误正确答案${question.options[question.correct_index]}; } // 禁用所有选项防止重复点击 const allOptions document.querySelectorAll(.option-item); allOptions.forEach(option { option.style.pointerEvents none; }); // 下一题 setTimeout(() { currentIndex; if (currentIndex questionList.length) { renderQuestion(); } else { submitAnswers(); } }, 1200); } async function submitAnswers() { try { const resp await fetch(/api/submit, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username: username, level: currentLevel, answers: answers }) }); const data await resp.json(); if (data.code ! 0) { alert(data.msg || 提交失败); return; } document.getElementById(question-area).style.display none; const resultArea document.getElementById(result-area); resultArea.style.display block; const { correct_count, total_count, score } data.data; resultArea.innerHTML h2挑战完成/h2 p答对${correct_count} / ${total_count}/p p得分${score}/p button onclicklocation.reload()再来一次/button ; } catch (err) { console.error(err); alert(提交答题结果失败); } }这段代码的关键点在于用户点击选项后不允许再次点击并且延迟 1.2 秒后再跳到下一题。这样设计是因为玩家需要时间看到“回答正确/回答错误”的提示否则跳题太快游戏体验会很差。另外需要注意pointerEvents none这种禁用点击的方式比给按钮添加disabled属性更简单而且不会影响样式继承。5.3 页面样式为了让页面看起来更清爽我准备了一份基础 CSS。移动端优先适合手机浏览器打开。/* 文件路径static/css/style.css */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: PingFang SC, Microsoft YaHei, sans-serif; background: linear-gradient(135deg, #fef9e7 0%, #fdebd0 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; } .container { width: 90%; max-width: 480px; margin: 20px auto; background: #fff; border-radius: 16px; padding: 20px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); } h1 { text-align: center; color: #e67e22; font-size: 26px; margin-bottom: 4px; } .subtitle { text-align: center; color: #888; margin-bottom: 16px; } .user-input { display: flex; flex-direction: column; gap: 12px; } .user-input input { padding: 12px 14px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; } .user-input button { padding: 12px; background: #e67e22; color: #fff; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; } .progress { text-align: center; margin-bottom: 12px; color: #555; } #image-box { width: 100%; height: 280px; background: #f7f7f7; border-radius: 12px; display: flex; justify-content: center; align-items: center; overflow: hidden; margin-bottom: 16px; } #image-box img { width: 100%; height: 100%; object-fit: cover; } .options-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } .option-item { padding: 14px 10px; text-align: center; background: #fdf2e9; border: 2px solid transparent; border-radius: 8px; font-size: 15px; cursor: pointer; transition: all 0.2s ease; } .option-item:hover { background: #fadbd8; border-color: #e67e22; } .option-item.correct { background: #abebc6; border-color: #2ecc71; } .option-item.wrong { background: #f5b7b1; border-color: #e74c3c; } #question-result { text-align: center; margin-top: 12px; font-size: 16px; font-weight: 600; } #result-area { text-align: center; padding: 20px 0; } #result-area h2 { margin-bottom: 16px; color: #333; } #result-area p { font-size: 18px; margin: 8px 0; } #result-area button { margin-top: 16px; padding: 12px 30px; background: #e67e22; color: #fff; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; }到这里项目已经可以跑起来了。接下来我们启动服务看看完整流程是否正常。6. 项目运行与预期结果打开终端进入项目根目录执行以下命令第一步安装依赖pip install -r requirements.txt第二步初始化数据库并插入测试数据python init_db.py执行后可以看到类似输出数据库初始化完成 插入 3 道关卡题目成功第三步启动 Flask 服务python app.py正常看到如下输出* Running on http://127.0.0.1:5000在浏览器中访问http://127.0.0.1:5000输入昵称后点击“开始挑战”页面会依次展示三道题目。选择答案后系统会显示对错并最终跳到成绩页面。如果你在初始化时遇到这个报错AttributeError: module sqlite3 has no attribute row_factory请检查你 Python 环境里的sqlite3模块是否被异常覆盖绝大多数情况下这是环境问题重新安装 Python 3.8 以上版本即可解决。再来看真实运行效果。第一关第一题给出的是“绿色心情”但图片只展示雪糕本体不显示外包装。四个选项分别是“绿色心情”、“绿豆沙”、“糯米糍”、“冰工厂”。很多玩家会误选“绿豆沙”因为两者颜色接近视觉上确实像。这就是第一关破防的主要来源。7. 常见问题与排查思路在开发或运行过程中可能会遇到一些常见问题。我把它们整理成一张排查表方便大家快速定位。问题现象常见原因解决思路访问接口返回 404路由路径拼写错误检查/api/question/int:level中的 level 是否为整数访问接口返回 400参数格式错误检查 POST 请求的 JSON 格式用户名不能为空图片加载不出来图片文件不存在确认 static/images 目录下存在对应的 jpg/png 文件JSON 返回中文乱码未使用 ensure_asciiFalse写入数据时使用json.dumps(..., ensure_asciiFalse)数据库初始化报错无法打开文件data 目录不存在在连接前执行os.makedirs(..., exist_okTrue)点选项后无法跳下一题setTimeout 被用户刷新打断检查浏览器 Console 是否有报错确认 JavaScript 文件已正确加载8080 端口被占用端口冲突修改app.run(port5001)或换一个空闲端口我还想强调一个很容易被新手忽略的问题在selectOption中如果用户连续点击同一个选项会导致answers数组重复记录答案。上面代码通过pointerEvents none避免了这个问题但如果后续你改用按钮的disabled属性也要记得在切换题目时移除禁用状态。8. 项目扩展思路从“第一关”到多关卡运营当前项目只实现了第一关的基础答题流程。如果你希望把它做成真正能长期运营的雪糕盲猜挑战可以从以下几个方向扩展。8.1 增加难度体系可以在questions表中新增difficulty字段用 1、2、3 分别代表基础、进阶、地狱难度。地狱难度可以做这样的大胆设计只用雪糕的横截面图片或者只用融化状态下的图片。越难的模式对题目图片的抽象程度要求越高。8.2 增加作答时间限制当前版本没有时间限制玩家可以慢慢思考。如果要做成更具挑战性的游戏可以给每道题增加时间限制比如 15 秒内作答超时自动跳到下一题并记为错误。前端实现思路如下let timer; function startTimer(seconds) { clearInterval(timer); let remain seconds; const timerBox document.getElementById(timer); timerBox.textContent remain s; timer setInterval(() { remain--; timerBox.textContent remain s; if (remain 0) { clearInterval(timer); handleTimeout(); } }, 1000); }8.3 增加排行榜展示当前后端已经提供/api/ranking接口但前端还没有调用。你可以在成绩页面渲染完后请求排行榜数据并展示前十名async function loadRanking() { const resp await fetch(/api/ranking); const data await resp.json(); if (data.code 0) { renderRanking(data.data); } }8.4 图片资源准备实际项目中雪糕图片要注意版权问题。推荐做法是自己购买雪糕后拍摄实物图。使用开源图库中的相似图片做占位。只用于学习演示不用于商业运营。如果直接抓取电商平台的商品图会存在较大的版权风险。9. 最佳实践与工程建议最后这部分我结合自己做小游戏项目的经验给大家几条实用建议。9.1 数据校验不能省虽然这是个练手项目但也要对用户输入做基本校验。后端submit_answer中已经校验了用户名、参数类型和答案列表这样做可以防止恶意请求导致程序异常。真实项目里建议再用 Flask-WTF 或 Pydantic 做更严格的校验。9.2 答案不要直接暴露在前端这个项目的玩具版本中/api/question直接返回了correct_index这意味着技术人员可以打开浏览器开发者工具直接获取正确答案。如果要上线运营正确做法是后端返回题目时不包含correct_index字段。用户提交答案后由后端判断对错。防止用户通过接口批量抓取答案。这属于接口安全设计的一部分。虽然小游戏不需要太高安全性但这种思路和业务系统是一致的。9.3 日志记录要做到位建议在每个接口的入口和出口添加日志方便定位问题。Flask 本身会打印请求记录但如果要追踪答题数据建议加上结构化日志import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) logger.info(用户 %s 提交第 %s 关答案共 %s 题, username, level, len(answers))9.4 前端状态管理要清晰目前用全局变量currentIndex、questionList、answers来维护游戏状态。如果题目数量增多建议使用一个GameState对象来统一管理避免多个函数互相改全局变量导致状态混乱。9.5 生产环境部署方式本地开发使用app.run(debugTrue)没问题但生产环境不要开启 debug 模式。推荐使用waitress或gunicorn作为 WSGI 服务器pip install waitress waitress-serve --host0.0.0.0 --port5000 app:app对于小型个人挑战项目SQLite 够用如果并发量上来建议迁移到 MySQL 或 PostgreSQL并给answers表的(level, score)字段加索引。10. 下一步可以怎么练写完这个项目你可以继续做几件事来巩固知识第一把前端改成 Vue 3 Vite 版本加深对前后端分离的理解。第二给题目加上图片上传后台做一个简易管理界面这样运营人员可以自己维护题库。第三增加微信小程序端复用同样的后端 API只是在请求方式和渲染逻辑上做适配。第四把你的成绩分享功能做出来这是让雪糕盲猜挑战真正“火起来”的核心功能。学习编程最好的方式就是不停给自己设计一个又一个“破防”的小目标。第一关让你破防但代码实现之后你反而会比普通人更懂这套游戏背后的设计和逻辑。如果这篇文章对你有帮助记得收藏备用。也可以用这套代码做一份专属于你自己的“雪糕盲猜挑战”发到班级群或者同事群里试试水看看到底有多少人能过第一关。