微信小程序狼人杀项目实战:状态机与实时同步全解析 📅 发布时间:2026/9/16 16:15:19 👁 浏览次数: 简介面向微信小程序初学者的狼人杀游戏完整项目覆盖从基础架构到核心玩法的全流程开发适合课程设计或实战练手。项目基于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房间人数达到配置点击开始nightnight所有角色行动完成speechspeech最后一个玩家发言完毕votevote票型统计完成且游戏未结束nightvote狼人全部出局或好人全部出局end2.2 微信小程序页面设计用布尔字段减少模板表达式坑页面设计上我见过很多同学把不同阶段的DOM用display:none和class切换控制结果一旦嵌套超过两层状态互相污染。正确的微信小程序页面设计思路是让data里的状态字段成为唯一渲染开关。在game.wxml里用block配合wx:if来分区块渲染。view classroom !-- 等待阶段 -- block wx:if{{isWaiting}} text房间号{{roomId}}/text button bindtapstartGame开始游戏/button /block !-- 夜晚阶段 -- block wx:elif{{isNight}} text天黑请闭眼/text view classrole-panel wx:if{{myRole wolf}} text今晚请选择刀人目标/text view classplayer-list view wx:for{{playerList}} wx:keyid >// 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里初始化为falsetargetId来自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_typeauthorization_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_atroom_players表保存room_id、user_id、role、alive。创建房间时生成一个6位不重复room_id玩家加入时向room_players表插入记录人数满足配置后再进入角色分配。接口路径方法入参说明/api/room/createPOSTuser_id创建房间返回room_id/api/room/joinPOSTroom_id, user_id加入房间返回当前人数/api/game/startPOSTroom_id人数足够时分配角色并进入夜晚/api/game/votePOSTroom_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头里放自定义tokenPHP端从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秒低PHPMySQL低学习项目、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?txxx它和页面内部路由不是一回事。调试时先在开发者工具里打开链接体验版确认能否跳到指定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 })视图层就会对整个渲染树做diff20人房间也会出现明显卡顿。推荐按字段精准更新例如更新某一个玩家的存活状态// 只更新一个玩家的存活字段 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)每次阶段切换都打印一次配合抓包能快速定位是哪一端先错了逻辑。发版前用条件编译注释掉这行既不影响运行也不留调试噪音。本文还有配套的精品资源点击获取