简介:这是以成语接龙为主题的微信小程序完整源码,面向对小程序开发感兴趣、希望学习游戏化设计与广告变现的开发者。项目实现了无限关卡玩法、横幅/激励视频/插屏广告集成、分享好友获能力等机制,可直观看到前端交互与后端接口配合方式。压缩包共九千九百二十八个文件,约六十二点九四兆,以js逻辑脚本、php服务端接口、png与svg图标素材、css/less样式以及wxml/wxss页面结构为主,目录清晰,便于按功能模块查阅。目前已有一千五百一十五人学习下载。通过阅读源码可掌握微信开发者工具操作、关卡数据结构设计、广告组件触发时机及社交裂变实现思路,同时学习版本管理习惯,适合刚入门小程序或想拓展游戏类项目的开发者参考。
1. 成语小秀才微信小程序源码,能跑起来的不只是答题页
拿到这份「成语小秀才微信小程序源码」,你最先想做的事估计和我一样:把它丢进微信开发者工具,点开编译,看看成语题库和闯关页面长什么样。这套源码的核心是一条完整的答题闯关闭环——首页选关、成语填空、提示求救、倒计时奖惩、金币累计,中间还夹着含 800 多组成语的数据文件,足够撑起一个能上架的小体量应用。对刚从小游戏、web 页面转过来的开发者来说,它的价值在于把题库结构、页面状态管理和闯关判定都做成了可以直接拆的范式;如果你手头正缺一个带完整数据层的小程序练手项目,或者想拿成语题库做二次开发,这份源码可以在一个晚上内帮你跑通全流程,适合当独立小程序开发的起步骨架,也适合做教学演示用的最小可运行样例。
2. 把项目拆开看:源码结构、题库文件与关卡配置
2.1 目录结构:哪些文件决定了小程序能编译运行
微信小程序原生项目打开后,第一眼看到的是 app.js / app.json / app.wxss 这老三样,接着是 pages 目录、utils 目录,以及一份独立存放的成语题库数据文件。这份源码的结构不算复杂,但分层比一般 demo 做得清楚:
- app.json 里注册了两个以上页面,常见的是首页(index)和答题页(quiz),部分版本还会有排行页或设置页;
- pages/index 负责关卡选择和金币展示,pages/quiz 承担答题交互;
- utils 目录放公共方法,比如随机抽题、洗牌、金币计算这类纯函数;
- 题库数据通常以 .js 或 .json 形式放在根目录或 data 目录,成语记录每一条包含成语本身、拼音、解释和示例句。
打开 app.json,你会看到类似这样的页面注册配置:
{ "pages": [ "pages/index/index", "pages/quiz/quiz" ], "window": { "navigationBarTitleText": "成语闯关", "navigationBarBackgroundColor": "#F5E6C8", "navigationBarTextStyle": "black" }, "style": "v2" }这段配置决定了编译后哪些页面可以被路由到,其中第一项 pages/index/index 会作为首页加载。navigationBarTitleText 是顶部导航标题,和页面上实际展示的标题是两个地方,很多新手改了半天标题发现没生效,就是改错了层级。navigationBarBackgroundColor 用古风米黄色契合主题,但如果你打算发布正式版本,建议把颜色统一到品牌主色,否则安卓和 iOS 两端顶部栏色差会比较明显。
再往下看 utils 里通常有一个工具文件导出公共函数,答题页通过 require 引用。值得留意的是数据文件如果在根目录命名为 js 格式,它导出的通常是一个数组或对象:
const idiomList = [ { word: '一心一意', pinyin: 'yī xīn yī yì', explanation: '形容做事专心一意,没有杂念。', example: '他学习时总是十分专一,一心一意地看书。' } ]; module.exports = { idiomList };这种 js 格式的数据文件比 json 格式多一个好处:可以在文件内部写注释,也可以按需把数据拆分导出,在微信开发者工具里调试时不会遇到 json 文件的注释报错问题。但注意,如果后续数据量涨到几千条,推荐改用独立的 JSON 文件加载,因为 js 文件里一次性把全部成语打进代码包,会明显增加主包体积,影响小程序的包体审核。
2.2 题库数据设计:字段越全,二次开发越省心
成语题库每条数据的字段设计,直接决定了答题页、排行榜、每日挑战这些功能好不好扩展。这份源码里每条成语记录包含 word、pinyin、explanation、example 四个字段,属于最实用的最小集。如果你计划做更复杂的玩法,建议至少再补两个字段:一个 difficulty 标记难度,一个 category 标记主题,比如动物、数字、自然、人物。
先看不补字段时候的抽题逻辑长什么样:
function pickQuestions(idiomList, count) { // 把原数组复制一份,避免抽题过程改动原始数据 let pool = idiomList.slice(); let picked = []; for (let i = 0; i < count && pool.length > 0; i++) { let randomIndex = Math.floor(Math.random() * pool.length); picked.push(pool.splice(randomIndex, 1)[0]); } return picked; }这里用 Math.random() 取整生成下标,核心是每次抽完就把该条从 pool 中移除,以此来保证同一次闯关不会抽到重复成语。math 对象在微信小程序里可以直接用,不需要额外引入依赖,但在真机上 Math.random() 的随机性表现一般,如果你要做抽卡或每日随机挑战这类对随机度要求更高的功能,可以考虑把随机种子和本地时间戳结合生成更分散的序列,常见做法是在取下标时这样处理:
let seed = Date.now() % 1000; let randomIndex = Math.floor(((Math.random() * seed) % pool.length) + Math.random() * pool.length * 0.5) % pool.length;这段做法谈不上最优,但能有效避免连续几次抽到相邻位置的成语,避免用户很快背出题库顺序。真正决定上手体验的其实是题库质量——源码自带的 800 组成语里,有不少是让人一眼能答出答案的常用成语,这类题目适合前五关;到后面关卡需要更生僻的成语拉高区分度,就需要你自己扩充了。扩库时最重要的一条红线是:不要把拼音和汉字写错,一个多音字标错就能让整个判断逻辑失效,用户在页面上怎么点都判定错误。
2.3 关卡与金币配置:从入门到调整参数
源码里关卡难度的梯度,是靠题库中成语的排列顺序和题目抽取范围控制的。常见做法是给每个成语加一个 mark 标记,前 100 条属于第一档难度,100 到 300 属于第二档,以此类推。与难度贴得最近的是金币奖励参数,每次过关在源码里会给 100 金币作为基础奖励,答错扣 10 金币,连续答对三题以上有加成。
金币计算逻辑一般写在过关回调里,例:
function calculateReward(base, streak, isPerfect) { let reward = base; if (streak >= 3) { reward += Math.floor(streak * 10); } if (isPerfect && streak >= 5) { reward += 50; } return reward; }参数计算逻辑简单但效果直接:基础奖励 base 控制经济系统整体水位,streak 连胜加成鼓励用户连续作答,isPerfect 全对奖励则给了高手额外成就感。我一般会把 base 设为 100,连胜加成 10 金币,全对再有 50 金币奖励,这个参数组合在用户留存上比较温和,不至于让金币通货膨胀过猛。要注意金币数据存储在本地缓存里,使用 wx.setStorageSync('coins', totalCoins) 同步写入;如果你改成异步的 wx.setStorage,会导致连续多次写入时出现覆盖时序问题,后写的把它前边的值覆盖掉。
3. 闯关核心逻辑:从渲染到判定的完整链路
3.1 答题页的界面与数据绑定
答题页是这份源码里信息密度最高的文件,它基本上是一个问答交互页面:上方显示待填成语的拼音提示和空白格子,中间位置列出若干个候选汉字,用户按顺序点击汉字填入空格,选错可以点击已填汉字将其退回到候选区,全部填对后自动进入下一题。
页面的 wxml 结构大致是这样的:
<view class="question-card"> <view class="pinyin">{{ current.pinyin }}</view> <view class="word-slots"> <view wx:for="{{ current.answerArray }}" wx:key="index" class="slot {{ item.filled ? 'filled' : '' }}" bindtap="onSlotTap" >startQuestion(question) { let wordArray = question.word.split(''); // 每个空格初始显示为空字符串 let answerArray = wordArray.map(char => ({ char: '', target: char, filled: false })); // 构造候选字面板,把当前成语的每个字拆开放入 let candidateChars = wordArray.map(char => ({ char: char, used: false })); this.setData({ current: question, answerArray: answerArray, candidateChars: candidateChars, selectedIndex: 0 }); }初始化方法里最关键的一步是给 answerArray 的每一项保留 target 字段,target 存的是这个位置本来应该填入的正确汉字,而 render 时只渲染 char。这样判定对错就不需要额外查题库,直接把用户填入的字符和该位置的 target 对比即可,省去了一次遍历查找。selectedIndex 记录当前应该填入第几个格子,相当于一个指向下一个空位的指针,用户按顺序点击汉字时,不需要自己先点空格再选字,这个设计大大降低了操作成本,但从另一面看也带来一个问题:只允许顺序填字,用户想从第二个字开始填就做不到了,对习惯“按字面顺序答题”的人来说很自然,但对先看首尾猜词的用户可能不太顺手。
3.2 点击判定与输入状态的边界处理
答题的核心事件有两个:onCharTap 负责点击候选字,onSlotTap 负责点击已填字的格子将其退回候选区。这两个事件一个正向一个反向,逻辑处理上最容易出 bug 的是状态同步。
先看正向流程的代码:
onCharTap(e) { let char = e.currentTarget.dataset.char; let index = e.currentTarget.dataset.index; let candidateChars = this.data.candidateChars; // 如果该字已经被使用,直接忽略本次点击 if (candidateChars[index].used) { return; } let answerArray = this.data.answerArray; let selectedIndex = this.data.selectedIndex; // 所有空格都已填满时不再接受新字 if (selectedIndex >= answerArray.length) { return; } answerArray[selectedIndex].char = char; answerArray[selectedIndex].filled = true; candidateChars[index].used = true; this.setData({ answerArray: answerArray, candidateChars: candidateChars, selectedIndex: selectedIndex + 1 }); // 全部填满后自动触发判定 if (selectedIndex + 1 === answerArray.length) { this.checkAnswer(); } }这段逻辑里有三个必须守住的状态边界:第一,已经 used 的汉字不能再次被点击,否则同一个字会被填进两个格子;第二,选字指针 selectedIndex 不能越过答案数组末尾,否则继续点击会导致数组下标越界,渲染层报错;第三,当最后一个格子刚被填满,在 setData 之后立即调用 checkAnswer,能保证用户在视觉上看到完整成语后再进入对错反馈。
反向操作 onSlotTap 同样有对应的边界条件:
onSlotTap(e) { let slotIndex = e.currentTarget.dataset.index; let answerArray = this.data.answerArray; // 只有已填充的格子才能被点击退回 if (!answerArray[slotIndex].filled) { return; } let letter = answerArray[slotIndex].char; let candidateChars = this.data.candidateChars; // 从右侧尚未使用的位置开始查找,找到与退回字相同的候选字 for (let i = 0; i < candidateChars.length; i++) { if (candidateChars[i].char === letter && candidateChars[i].used) { candidateChars[i].used = false; break; } } answerArray[slotIndex].char = ''; answerArray[slotIndex].filled = false; // 只要退回的格子位置比当前指针靠前,指针要回退 let newSelectedIndex = Math.min(slotIndex, this.data.selectedIndex - 1); this.setData({ answerArray: answerArray, candidateChars: candidateChars, selectedIndex: newSelectedIndex }); }这里比较值得说的一行是 newSelectedIndex 的计算:它取 Math.min(slotIndex, this.data.selectedIndex - 1)。仔细想一下,用户点击候选字填字时指针是往后走的,一般只会点击最后一个已填充的格子来做“后悔药”,这时 slotIndex 等于 selectedIndex - 1,回退后指针刚好等于原位置;但用户也可能在填了四个字后发现第二个字填错了,直接点了第二格把字退回,此时指针已经指向 4,如果不做处理,下一次点候选字会直接填到第五个不存在的格子上去。Math.min 的作用就是确保无论退回哪一个位置,指针最多回退一格,并且永远指向一个有效区间。这种指针与格子状态联动的写法,在类似的填字小程序里属于最稳的方案。
3.3 判定、过关与本地记录
checkAnswer 是闯关闭环的收口函数,它拿 answerArray 里每个格子实际填的字符,与对应位置的 target 逐一比对。全部一致时触发过关逻辑,弹出一个自定义动画层或消息提示,然后进入下一题;不一致时不直接判定失败,而是给出一个“再想想”的提示,把填错的字标红,并扣除少量金币。
判定函数参考写法:
checkAnswer() { let answerArray = this.data.answerArray; let correct = answerArray.every(item => item.char === item.target); if (correct) { this.handlePass(); } else { // 错的格子置为 error 状态,呈现红色样式 let updated = answerArray.map(item => { if (item.char !== item.target && item.filled) { return { ...item, error: true }; } return item; }); this.setData({ answerArray: updated }); wx.showToast({ title: '再想想', icon: 'none' }); } }这里用 Array.every 做全量比对,比 for 循环简洁且可读性高,在 4 个字的规模上性能差距可以忽略。错字标记 error 状态后,下一次点击候选字时应该把目标位置的 error 清除,否则视觉上会一直保留红色残影;在填写新字时清除上一个错误标记,是很多没做过此类交互的人容易漏掉的状态清理。
过关后的 handlePass 里会做三件事:累计金币、更新关卡进度、判断是否全关通关。关卡进度我一般用 wx.setStorageSync('unlockedLevel', level + 1) 保存,这样用户退出小程序再进来,能直接接着上次的关卡继续。这里如果你用的是页面的 data 里的全局变量来记录进度,那小程序一关掉数据就清零,用户会觉得自己没玩过,体验损失很大。
3.4 音频与动效:提示系统背后的联动细节
题库源码里除了答题逻辑,还集成了两个辅助系统:一是提示道具,二是答对答错时的音频反馈。提示道具的实现是拿到当前成语的第一个字作为免费提示,再往后用提示需要扣金币;音频反馈则通过 wx.createInnerAudioContext 完成。
创建音频上下文的典型写法如下:
const innerAudioContext = wx.createInnerAudioContext(); function playCorrectSound() { innerAudioContext.src = '/assets/audio/correct.mp3'; innerAudioContext.play(); }这段代码业务上不难,但容易忽略的是 InnerAudioContext 是全局单例,如果在页面 onLoad 里重复创建,会积累多个音频实例同时播放,在低端安卓机上表现成“声音重叠、破音”。更稳的做法是把音频上下文定义在 Page 对象外部,用页面级变量保存,页面销毁时调用 innerAudioContext.destroy() 释放资源。另一个常踩的点是音频资源路径不能放在条件编译分支里,如果用绝对路径,必须在路径前加斜杠,写成 '/assets/audio/correct.mp3',否则真机上会报“音频资源不存在”的错。
4. 上线前三次想骂人的排坑实录:数据格式、倒计时与音频黑匣子
4.1 题库字与候选字开关不一致,判对时出现“灵异现象”
现象:答对题目后,页面偶尔会弹出“再想想”,刷新一次再答又能过关,同一题结果却不稳定。
原因:题库里的成语字面是一个字符串,比如“一心一意”,拆分成候选字后出现了两个“一”和两个“意”,候选数组简单用 indexOf 查找匹配字时,会把第一个“一”标记为 used,导致用户填第二个空格时点击的“一”已经处于禁用状态,最终答案里空格缺失判作错误。
解决:候选字从一开始就按“独立个体”标记,每个元素保留独立 id,use 状态按 id 维护,不用字符相等判断。我在改造时给 candidateChars 每一项加了一个唯一索引字段,点击和退回都通过 id 定位,同字不同个体之间互不干扰。
4.2 倒计时归零后,页面还在偷偷走秒
现象:答题页有一个 30 秒倒计时,倒计时归零后弹窗提示超时,但关掉弹窗发现文字还在走负数。
原因:倒计时用的是 setInterval 每秒减 1,但超时弹窗里没有 clearInterval,定时器继续运行,界面上的数字就一路负下去。
解决:在超时分支里先把 timer 清掉,并且在 onHide 生命周期里也清一次定时器,防止用户切到后台再回来时定时器叠着跑。代码结构上可以把清除定时器的动作抽成一个 stopTimer 方法,在超时、答完、页面隐藏三种场景下复用。
4.3 真机预览时音频不响,开发者工具里却一切正常
现象:开发者工具里答对答错都有提示音,转到真机预览后完全无声。
原因:一种可能是音频文件格式或尺寸不合规,另一种最常见的是音频上下文被销毁后没有重新创建,开发者工具里销毁逻辑不会报错,真机上则是“无声音”的无声故障。
解决:把 InnerAudioContext 的创建移到每次播放前,播放后不销毁,只在 onUnload 时统一释放。如果还不能出声,检查音频文件比特率,不少工具导出的 mp3 在微信真机上兼容性一般,把音源压成 44.1kHz、96kbps 以下基本能解决。
4.4 金币数值越攒越少,甚至变成负数
现象:用户答题正确加了金币,返回首页再进答题页,金币反而比之前少了,有时会扣到负数。
原因:金币初始化和读取时机错乱,页面 onLoad 时用默认值覆盖了本地缓存里已有的金币,相当于每次进入都被重置了一遍。
解决:读取金币的代码要放在写入之前,并加一层判断——只有本地没有缓存时才初始化默认值,已有缓存则直接用缓存值,不做覆盖操作。这里我个人的习惯是封装成一个 getCoins 方法,内部统一处理读缓存、初始化和类型校验,所有页面都从它拿金币。
4.5 排行榜数字排对了,可页面渲染顺序不对
现象:排行榜列表里的分数是对的,但从高到低排序后页面展示出来并不是按分数高低排列。
原因:单纯 sort 后直接 setData,没有考虑 sort 是按字符串排序还是按数字排序的差异。数字分数被读成字符串,sort 方法按 ASCII 比较,导致 900 排在 1000 前面。
解决:排序前先把分数字段用 Number() 转成数字再 sort,并且在数据源源头保证分数写入时就是 number 类型。这个地方如果不做防御,不同微信基础库版本的表现还不一样,最阴的是部分版本表面上排对了,换了基础库又塌了。
5. 让成语题库从“能玩”变成“耐玩”:数据校验与玩法扩容的落地技巧
当这套源码的答题流程跑通后,下一步需要处理的就是成语数据本身的可维护性。我见过不少人对题库做二次开发时,直接把新成语一行行追加到数组尾部,结果出现重复条目、拼音缺失、前后空格等问题,轻则题目异常,重则点击某个成语时页面渲染报错。这里提供一个简单但有效的本地校验脚本思路:把题库作为 Node 脚本的可执行模块,跑一遍数据完整性检查。
校验脚本可以这样写:
const { idiomList } = require('./data/idioms.js'); let errors = []; idiomList.forEach((item, index) => { if (!item.word || item.word.length !== 4) { errors.push(`第${index + 1}条成语长度异常: ${item.word}`); } if (item.word.split('').some(char => char.trim() === '')) { errors.push(`第${index + 1}条成语包含空格: ${item.word}`); } if (!item.pinyin || item.pinyin.split(' ').length !== 4) { errors.push(`第${index + 1}条拼音异常: ${item.word}`); } }); if (errors.length > 0) { console.log('发现问题:'); errors.forEach(err => console.log(err)); } else { console.log('校验通过'); }这个脚本覆盖了三个最常翻车的点:成语字数必须为四、字面不能有空格、拼音数量必须和字数对应。如果以后要加入题目的难度字段,脚本里建议加一条枚举校验,防止有人把难度写成 discrete、normal 之外的任意值。
玩法的扩容方面,在不改动架构的前提下最值得加的是“每日一题”模式。实现思路是取今天日期,计算一个固定随机种子,从题库里稳定抽出一道题,保证当天所有用户拿到的是同一题,第二天再自动轮换。这样做的代码量很小,但能带来每日回访的理由。固定随机种子可以用年月日的数字求和作为 seed,示例逻辑如下:
function getDailyQuestion() { const now = new Date(); const seed = now.getFullYear() * 10000 + (now.getMonth() + 1) * 100 + now.getDate(); const index = seed % idiomList.length; return idiomList[index]; }每日一题背后真正的产品逻辑是给用户一个轻量的回归钩子,而不是靠排行榜那样的重度社交压力。这种设计成本低、效果好,我自己的项目里就是靠这一招把七日留存拉高了不少。
那次之后我也养成一个习惯:任何题库类项目提测前,必须强制跑一遍数据校验脚本,顺手再检查一次金币缓存读写顺序。小程序端的很多问题不是代码逻辑多深,而是数据模样的坑。希望这份拆解能帮你在改成语小秀才源码时少走几段弯路,把更多精力留给真正想做的玩法和体验创新。希望帮到你。
本文还有配套的精品资源,点击获取