简介:面向微信小程序初学者的狼人杀游戏完整项目,覆盖从基础架构到核心玩法的全流程开发,适合课程设计或实战练手。项目基于JavaScript、WXML和WXSS实现,包含七大模块:UI设计(房间创建、加入与角色选择页面)、游戏逻辑(角色随机分配、夜晚杀人、白天投票、特殊角色能力执行)、基于WebSocket的实时通信、云数据库存储、权限管理(仅房主可开始游戏)、用户体验优化(减少网络请求与合理缓存)以及测试与调试。资源共84个文件,以js逻辑脚本、wxml/wxss页面结构、png/gif/jpg图片素材为主,配合json配置与readme说明,压缩包仅352KB,轻量易部署。目前已有2438人学习浏览。解压后可获得可直接运行的完整项目,代码结构清晰,模块划分明确,并配有说明文档,能帮助开发者深入理解微信小程序云开发、实时交互与角色状态同步的实现思路,也便于二次扩展成更多玩法。
1. 微信小程序狼人杀项目实例:别让状态同步拖垮你
开始做微信小程序项目实例,选狼人杀当载体,比想象中更能练手。它表面是页面设计问题,实际是一个多端实时同步的状态机问题:房间里的每个玩家,都要在同一晚看到同样的角色分配,在同一时刻进入白天或投票。如果刚开始就把所有逻辑堆在页面的data里,你会发现调试bug的时间远超写界面时间。
真正的微信小程序游戏开发,核心并不是canvas动画或小游戏引擎,而是把游戏流程拆成可验证的模块。对正在写毕业设计、或者想用uniapp微信小程序做跨端项目的工程师来说,这个实例的价值在于:它让你在同一个项目里遇到微信登录、房间、角色、跳转、网络、性能这些主要坑。
后续章节按实现顺序走:先定义状态机,再打通PHP后端,接着处理实时通信和抓包,最后在发版前清理包体和渲染性能。你可以边看边在后台开通测试号,跟着写。
2. 微信小程序狼人杀项目实例:页面设计、状态机与数据驱动
2.1 状态机:狼人杀流程的“唯一事实来源”
在动手画页面之前,我会先把狼人杀流程压缩成5个阶段:等待、夜晚、发言、投票、结算。夜晚阶段内部还要区分狼人刀人、女巫救人或毒人、预言家验人,但对外仍然是一个阶段,因为每个角色完成动作的时间点不同。如果直接把阶段散落在各个页面的data里,就会出现“玩家B还在发言,玩家A已经看到投票界面”的错乱。所以我在项目根目录放一个gameState.js,把所有阶段转移集中在一块。
// gameState.js const GAME_PHASE = { WAITING: 'waiting', NIGHT: 'night', SPEECH: 'speech', VOTE: 'vote', END: 'end' }; // 状态转移白名单,key是当前阶段,value是允许到达的阶段 const TRANSITIONS = { waiting: ['night'], night: ['speech'], speech: ['vote'], vote: ['night', 'end'], end: [] };这份定义同时给前端页面和后端PHP接口用,避免两端各写一套。参数说明:WAITING是房间匹配阶段;NIGHT是角色行动阶段;SPEECH是轮流发言阶段;VOTE是投票阶段;END是结算阶段。TRANSITIONS白名单的意义在于拦截非法转移,比如投票阶段必须经过night才能进入下一轮对话,不能直接回到speech,否则会出现已死者还能发言的问题。
因为转移条件不只是“点击按钮”,还要等所有角色完成动作,所以我把“进入下一阶段”封装成changeTo方法。比如夜晚阶段,要等刀人、验人、救援或毒药都结束后才调用changeTo('speech')。
// gameState.js let currentPhase = GAME_PHASE.WAITING; function changeTo(nextPhase) { if (!TRANSITIONS[currentPhase].includes(nextPhase)) { console.warn(`非法转移: ${currentPhase} -> ${nextPhase}`); return false; } const prevPhase = currentPhase; currentPhase = nextPhase; onPhaseChange(prevPhase, nextPhase); return true; }说明:changeTo先查白名单,再更新当前阶段,然后调用onPhaseChange回调,由回调去通知页面和WebSocket。参数:prevPhase用来做页面提示和上报,nextPhase是目标阶段。如果你不是原生开发,而是用uniapp微信小程序,这份状态机可以直接放进vuex或pinia,只是把回调换成store.commit,思路相同。
下面这张表是状态转移的完整约束,后端分配角色也依赖它:
| 当前阶段 | 触发操作 | 下一个阶段 |
|---|---|---|
| waiting | 房间人数达到配置,点击开始 | night |
| night | 所有角色行动完成 | speech |
| speech | 最后一个玩家发言完毕 | vote |
| vote | 票型统计完成且游戏未结束 | night |
| vote | 狼人全部出局或好人全部出局 | end |
2.2 微信小程序页面设计:用布尔字段减少模板表达式坑
页面设计上,我见过很多同学把不同阶段的DOM用display:none和class切换控制,结果一旦嵌套超过两层,状态互相污染。正确的微信小程序页面设计思路是:让data里的状态字段成为唯一渲染开关。在game.wxml里,用block配合wx:if来分区块渲染。
<view class="room"> <!-- 等待阶段 --> <block wx:if="{{isWaiting}}"> <text>房间号:{{roomId}}</text> <button bindtap="startGame">开始游戏</button> </block> <!-- 夜晚阶段 --> <block wx:elif="{{isNight}}"> <text>天黑请闭眼</text> <view class="role-panel" wx:if="{{myRole == 'wolf'}}"> <text>今晚请选择刀人目标</text> <view class="player-list"> <view wx:for="{{playerList}}" wx:key="id" >// pages/game/game.js tapKill(e) { // 锁住按钮,防止重复提交 if (this.data.pending) return; const targetId = e.currentTarget.dataset.id; const roomId = this.data.roomId; this.setData({ pending: true }); wx.request({ url: `${getApp().globalData.baseUrl}/api/game/kill`, method: 'POST', data: { roomId, targetId }, success(res) { if (res.data.ok) { getApp().event.emit('phase:updated', res.data.phase); } else { wx.showToast({ title: res.data.message, icon: 'none' }); } }, complete: () => { this.setData({ pending: false }); } }); }参数说明:pending必须在data里初始化为false;targetId来自WXML中的>// Api/LoginController.php public function login($code) { // 调用微信code2session接口 $url = 'https://api.weixin.qq.com/sns/jscode2session' . '?appid=' . urlencode($this->appid) . '&secret=' . urlencode($this->appSecret) . '&js_code=' . urlencode($code) . '&grant_type=authorization_code'; $response = $this->httpGet($url); if (!$response) { return ['code' => 1, 'message' => '请求微信失败']; } $data = json_decode($response, true); if (isset($data['openid'])) { $token = $this->createToken($data['openid']); return ['code' => 0, 'token' => $token]; } return ['code' => 1, 'message' => $data['errmsg']]; }
参数说明:appid和secret在微信公众平台的开发设置里查看;js_code就是wx.login返回的code,一个code只能使用一次,有效期约5分钟;grant_type固定传authorization_code。httpGet是自己封装的curl函数,生产环境必须用curl并设置连接超时,不能直接依赖allow_url_fopen。createToken是自行实现的登录态生成,可以使用JWT或随机串+Redis/session存储。
前端这段登录逻辑很简洁:
// app.js 中登录 wx.login({ success: async (res) => { const rs = await request('/api/login', { code: res.code }, 'POST'); wx.setStorageSync('token', rs.token); } });注意:不要在小程序端直接请求jscode2session接口,因为secret放到小程序包里等于公开泄露,任何人都能从包里扒出来。
3.2 PHP后端:房间创建与角色分配接口
微信小程序的后端用PHP是如何实现的?我一般用原生PHP写业务接口,搭配MySQL做持久化。狼人杀项目实例里,最小数据集是两张表:rooms表保存room_id、phase、created_at;room_players表保存room_id、user_id、role、alive。创建房间时生成一个6位不重复room_id,玩家加入时向room_players表插入记录,人数满足配置后再进入角色分配。
| 接口路径 | 方法 | 入参 | 说明 |
|---|---|---|---|
| /api/room/create | POST | user_id | 创建房间,返回room_id |
| /api/room/join | POST | room_id, user_id | 加入房间,返回当前人数 |
| /api/game/start | POST | room_id | 人数足够时分配角色并进入夜晚 |
| /api/game/vote | POST | room_id, user_id, target_id | 记票并统计结果 |
下面这段是9人局的角色分配,角色池大小必须等于玩家数,否则后面会有人拿不到角色。
// GameService.php public function assignRoles($roomId) { $players = $this->getPlayers($roomId); $roleConfig = [ 'wolf' => 2, 'seer' => 1, 'witch' => 1, 'villager' => count($players) - 4 ]; $pool = []; foreach ($roleConfig as $role => $count) { for ($i = 0; $i < $count; $i++) { $pool[] = $role; } } shuffle($pool); // 随机洗牌,等价于抽签 foreach ($players as $i => $player) { $this->updatePlayerRole($player['id'], $pool[$i]); } $this->updateRoomPhase($roomId, 'night'); }参数说明:roleConfig可以按玩家人数调整,例如12人局再加预言家和猎人;shuffle是PHP内置洗牌,能保证角色分布随机;updatePlayerRole和updateRoomPhase要用数据库事务包裹,避免分配一半时接口中断导致房间数据错乱。这里直接把阶段设为night,前端收到这个状态就会切到夜晚界面,不需要再单独调用修改阶段接口。
3.3 请求封装与域名校验
前后端联调时,最常出现的问题是开发环境能打开、真机上全失败。原因是微信小程序要求所有request域名必须配置在白名单里。开发时可以在开发者工具里勾选“不校验合法域名”,但体验版和正式版绕不开。我习惯在utils/request.js里统一封装。
// utils/request.js const request = (url, data = {}, method = 'GET') => { const token = wx.getStorageSync('token'); console.log(`[request] ${method} ${url}`, data); return new Promise((resolve, reject) => { wx.request({ url: `${getApp().globalData.baseUrl}${url}`, data, method, header: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 400) { resolve(res.data); } else { reject({ code: res.statusCode, message: res.data?.message }); } }, fail: reject }); }); };参数说明:baseUrl在app.js里根据环境切换,比如开发环境用本地IP,测试环境用已备案的HTTPS域名;Authorization头里放自定义token,PHP端从header里解析出用户身份。这里把console.log留在封装里,便于开发期排查请求参数,发布前再按环境关闭。要注意微信小程序没有浏览器那种跨域限制,但后台域名的ICP备案和TLS证书必须到位,否则请求直接报url not in domain list。
4. 微信小程序狼人杀项目实例:实时通信、页面跳转和抓包定位
4.1 轮询和WebSocket怎么选
狼人杀是回合制,不是射击游戏,所以轮询也能跑通,但代价是后端请求量很夸张。假设一个房间20人,每人3秒查一次状态,一分钟就有400次请求,20个房间就是每秒130多次,PHP加MySQL如果不加缓存会先撑不住。我一般建议小项目用WebSocket,但如果后端团队不熟悉常驻进程,先从轮询起步也可以,方便快速验证游戏逻辑。
| 方案 | 平均延迟 | 后端成本 | 代码复杂度 | 推荐场景 |
|---|---|---|---|---|
| 3秒轮询 | 3-5秒 | 低,PHP+MySQL | 低 | 学习项目、20人以下房间 |
| WebSocket长连接 | 200-500ms | 高,需要常驻进程 | 中 | 正式运营、大量房间 |
| 云实时数据库 | 100-300ms | 低,按量付费 | 低 | 小团队快速迭代 |
前端建立WebSocket连接时,我会把roomId拼在查询参数里,服务端按照roomId把消息投递给同一个房间。连接后的onMessage统一处理阶段变更、玩家上下线、投票结果三类消息。
// utils/socket.js // 连接房间实时通道 function connectRoom(roomId) { return new Promise((resolve, reject) => { const url = `${getApp().globalData.wsUrl}/ws?roomId=${roomId}`; const task = wx.connectSocket({ url }); task.onOpen(() => resolve(task)); task.onError(reject); task.onMessage((msg) => { const packet = JSON.parse(msg.data); if (packet.type === 'phase') { getApp().globalData.applyPhase(packet.data); } else if (packet.type === 'vote') { getApp().globalData.updateVoteState(packet.data); } }); }); }参数说明:wsUrl必须是wss协议,并且要在微信公众平台配置socket合法域名;connectSocket返回的task需要保存到页面实例上,页面卸载时调用task.close(),否则每一次离开房间都会多一条连接。onMessage里只处理增量消息,phase消息携带完整阶段快照,vote消息只携带当前票数,这样能减少不必要的setData。
4.2 页面跳转:navigateTo、redirectTo和外部链接
游戏内的页面跳转有两个原则:能返回的使用navigateTo,不能再返回的用redirectTo。从房间列表进入一个进行中的房间,使用navigateTo,因为玩家可能还要退出重进;一局结束后从结算页回大厅,一定要用redirectTo,否则用户按返回键会回到已经结束的房间页面,还会触发状态错乱。
// 返回可返回的房间 wx.navigateTo({ url: `/pages/game/game?roomId=${roomId}` }); // 结算后回房间列表 wx.redirectTo({ url: '/pages/index/index' }); // 分享邀请卡片 onShareAppMessage() { return { title: `狼人杀房间 ${this.data.roomId}`, path: `/pages/index/index?invite=${this.data.roomId}` }; }页面之间只能传字符串参数,如果要从房间页带一整个玩家列表到结算页,建议放在全局变量或缓存里,不要在URL里拼JSON。参数说明:roomId在game页面的onLoad(options)里通过options.roomId读取;invite参数在首页onLoad里读取后自动调用加入房间接口。还要注意页面栈最多10层,连续navigateTo超过10层后新的跳转会失败,所以循环进入多局游戏时必须在关键节点用redirectTo或reLaunch。
如果你需要在短信、群里拉起小程序,可以生成微信小程序跳转链接。这类外部链接在微信后台生成,格式类似weixin://dl/business?t=xxx,它和页面内部路由不是一回事。调试时先在开发者工具里打开链接体验版,确认能否跳到指定path和参数,再发到手机上从外部打开。如果没反应,优先看链接是否过期、path是否填写、参数是否正确。
4.3 用抓包解决“数据对不上”的问题
前后端状态不一致是狼人杀联调里最常见的故障,前端显示进入夜晚,后端phase还是waiting。遇到这种问题,不要只靠console.log,我一般会抓包确认请求本身是否到达、返回了什么。最轻量的方式是直接用微信开发者工具的Network面板,它能展示wx.request的URL、请求头、body和响应。
要抓手机上的真实流量,就用Charles抓包电脑端微信小程序。步骤是:电脑和手机连同一个Wi-Fi,手机设置HTTP代理指向电脑IP和8888端口,安装并信任Charles根证书,然后在微信开发者工具里用“真机调试”扫码。抓包后能看到HTTPS请求的明文内容,包括请求参数和后端返回结果。如果你只是简单确认请求参数,也可以直接在request封装里加日志,在真机调试的vConsole里查看。
// 在request.js中临时添加 console.log(`[api] ${method} ${url}`, data);注意:抓包只能看到小程序发出的HTTP请求,看不到WebSocket的实时帧内容;如果要排查WebSocket消息,要在onMessage里打日志,或者用开发者工具自带的Socket调试面板。后端接口也要在入口处记录error_log,把openid和请求参数一起打出来,这样一旦出现角色数据不一致,能顺着日志还原当时的现场。
5. 微信小程序狼人杀项目实例:发布前的包体、渲染与性能检查
5.1 主包2MB放不下?分包把静态资源搬走
狼人杀的角色立绘和音频很容易让主包超过2MB。常见做法是把角色详情页和对应资源放入分包,主包只保留大厅、游戏、结算三个核心页面。在app.json里配置:
{ "pages": [ "pages/index/index", "pages/game/game", "pages/settle/settle" ], "subpackages": [ { "root": "packages/role", "pages": [ "pages/role-detail/role-detail" ] } ] }说明:分包root目录下的文件在用户进入分包页面时才下载,但分包里的图片和音频仍可以通过绝对路径引用,比如/packages/role/images/wolf.png。参数方面,subpackages的root不能以斜杠开头,pages路径是相对root的。发版前可以在开发者工具的“代码分析”里看主包大小,如果超过1.8MB就要考虑再拆。
5.2 用setData局部更新,别把状态机全量塞回去
游戏过程中频繁变化的是玩家存活状态、票数、当前发言位置。如果你每次收到状态都执行this.setData({ gameState: wholeState }),视图层就会对整个渲染树做diff,20人房间也会出现明显卡顿。推荐按字段精准更新,例如更新某一个玩家的存活状态:
// 只更新一个玩家的存活字段 this.setData({ [`playerList[${index}].alive`]: false });这种动态键路径写法,是微信小程序setData支持的特性,也是解决嵌套对象更新时“只能整体赋值”问题的常用方式。参数说明:index是玩家在playerList中的下标,alive字段改变后,模板里wx:if="{{item.alive}}"会自动刷新。如果要一次改多个字段,可以合并成一个对象再setData,尽量减少setData调用次数。
| 更新内容 | 推荐写法 | 不推荐 |
|---|---|---|
| 单个玩家存活状态 | 动态键路径 | 全量更新playerList |
| 阶段切换 | 一次setData更新isWaiting等布尔量 | 直接setData整个状态机 |
| 大量操作日志 | 截断最近50条再更新 | 每次把完整数组塞回去 |
5.3 压测和真机验证
发布前我在开发者工具里做两个检查:一是在“真机调试”下看首屏渲染时间,二是把网络切换到“慢速3G”模拟弱网。狼人杀最容易卡的位置是房间进入瞬间,因为要同时加载房间信息、玩家头像和阶段状态。如果发现点击按钮后超过200ms才响应,先判断是网络慢还是setData渲染慢,再对症处理。
最后一个调试技巧:在gameState.js的changeTo函数里加一行console.log('[phase]', prevPhase, nextPhase),每次阶段切换都打印一次,配合抓包能快速定位是哪一端先错了逻辑。发版前用条件编译注释掉这行,既不影响运行,也不留调试噪音。
本文还有配套的精品资源,点击获取