拆解微信小程序狼人杀源码:从状态机到实时联机

发布时间:2026/9/16 19:40:04
拆解微信小程序狼人杀源码:从状态机到实时联机 简介面向微信小程序初学者的狼人杀游戏完整项目源码包覆盖从UI界面到游戏逻辑、WebSocket实时通信、云数据库存取和权限管理的全流程开发要点。rar压缩包共84个文件、352KB其中16个js负责角色分配、夜晚杀人、投票等核心逻辑10个wxml与10个wxss搭建页面结构和样式16个png、9个jpg及18个gif提供头像、图标与动效素材4个json为项目配置1个md说明便于快速上手。资源已有2438人学习。代码目录按chooseRole、hunter、witch、playground等页面拆分配合utils与engine封装的工具和事件模块可直观理解小程序模块化组织方式也方便二次改造或扩展玩法。对想通过实战掌握微信小程序开发、了解完整社交游戏链路的初学者和开发者是一份紧凑且可运行的参考资料。1. 微信小程序狼人杀项目一个可以拆开玩的社交推理实战拿到这份Werewolf-kill源码时我第一反应是翻开pages目录看页面划分——chooseRole、playground、witch、hunter几乎每个角色都占了一个页面这和大多数小游戏把所有交互堆在一个页面的做法很不一样。狼人杀这类社交推理游戏搬到微信小程序上最麻烦的不是规则本身而是多玩家实时状态同步、角色能力异步触发、以及夜晚行动和白天发言这些阶段的平滑切换。这份源码适合正在做微信小程序游戏开发的人尤其是想搞明白角色分配、事件总线和状态机怎么组织的新手即使你不玩狼人杀里面的页面通信和本地状态管理思路也能直接迁移到其他回合制多人游戏里。我在这里按“从静态页面到动态逻辑”的顺序拆开讲所有代码都基于微信开发者工具的基础库运行。2. 微信小程序项目结构与WXML/WXSS/JS的模块划分2.1 从文件清单理解项目骨架先看资源包里的核心目录不要着急跑代码把文件归属搞清楚能节省很多排查时间。微信小程序要求pages目录下每个页面至少是四个同名前缀文件js、json、wxml、wxss而templates目录则存放可复用的模板片段和页面引用方式不同。路径作用pages/index首页入口负责创建/加入房间pages/chooseRole角色选择页pages/playground游戏主场景承接大部分交互pages/witch女巫专用操作页pages/hunter猎人专用操作页pages/history历史记录页templates/titlebar、actionArea等可复用WXML模板utils/util.js洗牌、时间格式化等公共函数app.js / app.json小程序入口与全局配置这个结构最大的特点是页面按角色拆开而不是按游戏阶段收敛。好处是女巫能用毒和解药、猎人能开枪这些特殊逻辑各自独立不会污染主流程代价是页面之间不能直接互相调用方法必须借助全局数据或事件总线把动作回传给核心逻辑。资源包里dependencies没有看到第三方库说明作者刻意用原生api完成了所有功能这对学习底层很有价值。2.2 页面注册与全局配置打开app.json你会发现pages数组的第一个元素就是首页。微信小程序要求所有页面必须在这里注册否则开发者工具直接报page not found。我一般习惯把入口页放第一位然后才放功能页这样小程序的初始加载不会错过主路径。{ pages: [ pages/index/index, pages/chooseRole/chooseRole, pages/playground/playground, pages/witch/witch, pages/hunter/hunter, pages/history/history ], window: { navigationBarTitleText: 狼人杀, backgroundColor: #1a1a1a, navigationBarBackgroundColor: #1a1a1a, navigationBarTextStyle: white }, style: v2 }window配置里的navigationBarTitleText会显示在导航栏backgroundColor和navigationBarBackgroundColor都设成深色是为了让真机上loading界面和页面主题一致。这里有个容易被忽略的点navigationBarTextStyle只有white和black两个有效值深色背景必须配white否则顶栏字会看不清。style字段v2表示启用新版组件样式如果你项目里用了一些老组件改成v2后布局可能轻微变化。2.3 WXML模板与WXSS样式的复用templates目录里的titlebar.wxml和actionArea.wxml是复用度最高的两部分。WXML的template语法很简单通过is指定模板名data传入数据例如在playground页面中template istitlebar data{{title: 夜晚, subtitle: 请狼人行动}}/对应的titlebar.wxml里定义template nametitlebar view classtitlebar text classtitlebar-title{{title}}/text text classtitlebar-subtitle{{subtitle}}/text /view /template这里注意template不支持slot也不能绑定事件函数只适合纯展示型片段。如果你需要按钮点击甚至倒计时控制建议改用Component。我在实战中常把template和Component混用纯标题用template带交互的actionArea用Component。WXSS中尺寸单位建议全部用rpx它按屏幕宽度750px换算比如width: 200rpx在不同机型上视觉宽度一致而边框、阴影这类不希望随屏放大的属性用px更稳定。2.4 页面级实现index页的创建与加入房间看index.wxml的骨架view classcontainer image src/images/cover.jpg modeaspectFit/ input placeholder输入房间号 bindinputonRoomInput/ button bindtapcreateRoom typeprimary创建房间/button button bindtapjoinRoom typedefault加入房间/button /viewindex.js里createRoom做的事很简单生成一个六位随机房间号保存到globalData然后跳转到chooseRole。joinRoom则读取输入框的值存起来再跳转。这里的bindinput每次输入都会触发onRoomInputevent.detail.value就是当前内容不要误用this.data.roomNum event——微信小程序的setData才是唯一正确的数据更新方式。如果你在这个页面就开始接服务端记得在跳转前校验房间号非空。3. 狼人杀核心逻辑角色配置、事件总线与游戏引擎3.1 角色数据模型游戏逻辑的核心在roles.js里。这个文件是整局的配置中心定义了每个角色的阵营、技能、图标、可否被投票等属性。我建议把这类数据集中管理不要散落在页面里否则后期加一个新角色就要改四个页面。看一个典型的角色配置结构// roles.js export const ROLES { WEREWOLF: { id: werewolf, camp: wolf, name: 狼人, icon: /images/werewolf.jpg, skill: night_kill, canVote: true }, SEER: { id: seer, camp: good, name: 预言家, icon: /images/seer.jpg, skill: night_check, canVote: true }, WITCH: { id: witch, camp: good, name: 女巫, icon: /images/witch.jpg, skill: night_save_and_poison, canVote: true }, HUNTER: { id: hunter, camp: good, name: 猎人, icon: /images/hunter.jpg, skill: shoot_on_death, canVote: true } };注意skill字段是字符串标识不是函数。原因有三个第一配置对象如果包含函数在复制、序列化或跨页面传递时会丢失第二技能实现逻辑分散在engine.js和个页面里用字符串可以在代码里快速判断并分发第三后续从云函数拉取配置时可以保证数据结构简单。canVote字段决定这个角色在白天投票阶段是否可以被投出局比如猎人被毒死时不能开枪这个字段不能替代技能判断但能减少很多无效分支。下面这张表格列出了角色配置里最关键的字段方便你对照着写自己的配置字段类型含义idstring角色唯一标识用于引擎匹配campstring阵营wolf/goodnamestring显示名iconstring头像路径skillstring技能标识canVoteboolean白天是否可被投票3.2 角色随机分配算法狼人杀开局必须保证玩家无法预测角色顺序Fisher-Yates洗牌算法是标准做法。如果只调用Math.random塞进数组数组头部元素的随机性会越来越差。看util.js里这段// utils/util.js 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; }这个算法从数组末尾往前遍历每次随机出一个比当前索引小或相等的下标交换位置。时间复杂度O(n)且能保证每个排列出现的概率相等。接着用一个assignRoles函数把配置展开成角色数组再洗牌function assignRoles(playerCount, roleConfigList) { const roles []; roleConfigList.forEach(item { for (let i 0; i item.count; i) { roles.push(item.id); } }); if (roles.length ! playerCount) { console.warn(角色数量与玩家人数不匹配); } const shuffled shuffle(roles); return shuffled.map((roleId, index) ({ playerId: index 1, roleId, alive: true })); }参数roleConfigList是一个数组每一项包含roleId和count例如[{ roleId: werewolf, count: 2 }, { roleId: seer, count: 1 }]。这个函数返回玩家数组playerId从1开始。注意联机对战时角色分配必须在服务端完成否则客户端能看到所有玩家的roleId直接用Memory读取就能透视全场。本地调试时用这个函数没问题。3.3 EventBus页面间通信的解耦项目按角色拆页面后女巫用药、猎人开枪发生在独立页面但核心引擎要统一处理没有EventBus就会变成页面直接改全局数据互相污染严重。EventBus本质是发布订阅模式实现很简单// EventBus.js const listeners {}; export function on(event, callback) { if (!listeners[event]) { listeners[event] []; } listeners[event].push(callback); } export function off(event, callback) { const index (listeners[event] || []).indexOf(callback); if (index -1) { listeners[event].splice(index, 1); } } export function emit(event, data) { (listeners[event] || []).forEach(cb cb(data)); }用法上engine.js在进入夜晚阶段时emit(night_start, {werewolfId, targetId})witch页面在onLoad里注册on(night_start, this.handleNightStart)玩家操作后emit(witch_confirm, {save: true, poison: false})。这里有一个必须遵守的约定页面onLoad里注册的监听必须在onUnload里off否则页面销毁后回调仍然存在函数内setData会触碰已销毁页面的数据层导致控制台报错甚至白屏。最常见的错误是直接传this.handleNightStart如果这个函数没有绑定thisoff时拿到的是不同的函数引用解法是页面里先保存this.boundHandler this.handleNightStart.bind(this)注册和销毁都使用同一个引用。3.4 游戏引擎的状态流转engine.js是整个游戏的主控模块它维护一个有限状态机每个状态代表一个游戏阶段阶段切换时统一触发EventBus事件。下面是一个简化但可运行的状态定义const GameState { WAITING: waiting, NIGHT_WEREWOLF: night_werewolf, NIGHT_WITCH: night_witch, NIGHT_SEER: night_seer, DAY_DISCUSS: day_discuss, DAY_VOTE: day_vote, HUNTER_ACTION: hunter_action }; export class Engine { constructor(config) { this.players []; this.state GameState.WAITING; this.dayCount 1; this.config config; } startGame() { this.players assignRoles(this.config.playerCount, this.config.roleList); this.transitTo(GameState.NIGHT_WEREWOLF); } transitTo(nextState, payload) { this.state nextState; emit(state_changed, { state: nextState, payload }); } }状态机的好处是所有的阶段转移都集中在同一个方法里你可以在这个方法中打日志、做权限校验、记录时间戳。常见错误是把游戏阶段放在各个页面里各自维护结果狼人页显示“夜晚”女巫页显示“等待”状态永远对不齐。这里transitTo是唯一修改state的入口任何页面都不能直接改engine.state只能调用transitTo这样就能保证全局只有一个数据源。4. 实时联机WebSocket通信与云数据存储4.1 为什么用WebSocket而不是HTTP轮询狼人杀的关键动作投票、杀人、验人要求毫秒级送达HTTP请求只能由客户端发起服务器无法主动推送消息。比如狼人刚刀完人女巫必须立刻知道死讯如果用轮询每次至少间隔1秒体验会很差。微信小程序原生提供了wx.connectSocket接口和浏览器的WebSocket类似。另一种方案是微信云开发的实时数据推送它能把数据库集合的变更实时同步给前端但有一个限制客户端只能监听自己创建的或权限允许的数据。对于一局房间内的消息广播WebSocket更灵活。4.2 WebSocket连接与心跳保活下面是一个可用的WebSocket封装注意微信小程序里不能直接new WebSocket()必须先调用wx.connectSocket再通过onSocketOpen等回调处理后续流程// utils/ws.js let socketOpen false; let messageQueue []; function connect(url) { wx.connectSocket({ url }); wx.onSocketOpen(() { socketOpen true; messageQueue.forEach(msg send(msg)); messageQueue []; startHeartbeat(); }); wx.onSocketMessage(res { const data JSON.parse(res.data); emit(ws_message, data); }); wx.onSocketClose(() { socketOpen false; reconnect(url); }); } function send(msg) { const data JSON.stringify(msg); if (socketOpen) { wx.sendSocketMessage({ data }); } else { messageQueue.push(data); } } function startHeartbeat() { setInterval(() { send({ type: ping, ts: Date.now() }); }, 30000); }参数说明url是WebSocket服务的完整地址必须使用wss://协议微信小程序在真机上不认ws://开发工具可以。心跳间隔30秒是常见值具体要根据服务端超时时间调整如果服务器60秒未收到数据会断开心跳最好小于45秒。队列机制保证连接未建立时的消息不丢失但要注意消息有最大长度限制在微信小程序里单条socket消息默认上限为128KB应用层要自己拆包或压缩。收到数据后emit(ws_message)业务层再根据data.type分发处理。4.3 云数据库存储房间与游戏记录如果后端不想自己建服务器微信云开发会省去很多运维工作。云数据库天然集成权限控制比如只允许创建者修改房间其他玩家只能读部分字段。这里给出一个rooms集合的建议结构字段类型说明_idstring房间号唯一playersarray玩家ID列表rolesarray角色分配结果服务端加密currentStatestring当前游戏阶段creatorOpenidstring房主openidcreatedAtdate创建时间小程序端不要直接写入这个集合所有变更走云函数。下面是一个更新阶段的云函数示例// cloudfunction/updateGame const cloud require(wx-server-sdk); cloud.init(); const db cloud.database(); exports.main async (event) { const { roomId, newState } event; const wxContext cloud.getWXContext(); const room await db.collection(rooms).doc(roomId).get(); if (room.data.creatorOpenid ! wxContext.OPENID) { return { code: -1, msg: 只有房主可以操作 }; } await db.collection(rooms).doc(roomId).update({ data: { currentState: newState } }); return { code: 0, data: newState }; };这段代码的权限判断要点不要从event参数里拿openid要用wxContext.OPENID否则任何人都可以伪造房主身份。云函数返回值统一用{ code: 0 }表示成功非0表示失败前端拿到code后决定是否执行后续动作。如果角色分配在云函数里完成还要注意roles字段里存放的是加密后的角色映射不能让不同玩家拉取到全量明文角色否则通过查询数据库就能看到所有人身份。4.4 登录与身份绑定微信小程序登录流程wx.login获取临时code传给后端后端用code换openid和session_key。在云开发环境中可以直接用cloud.getWXContext()拿到openid不需要自己处理code换session。实战中要把openid哈希成一个短玩家ID用于房间内显示客户端之间不直接传openid避免隐私泄漏。同时房间的players数组在加入时要校验人数和重复加入否则狼人杀神职玩家可以开多个小号看到所有身份。5. 渲染性能优化与调试setData、组件化与真机验证5.1 降低setData频率与数据量微信小程序性能瓶颈大多集中在setData。setData会将数据从逻辑层传到渲染层数据量过大或频率过高都会导致掉帧。狼人杀里倒计时每秒更新、玩家列表频繁增删、聊天区插入新消息这些都会触发setData。优化原则有三个只更新变化的部分用数据路径更新嵌套字段高频更新拆到独立字段并节流。先看不推荐写法// 不推荐整个对象全量更新 this.setData({ gameInfo: { ...this.data.gameInfo, players: newPlayers, state: night, timer: 10 } }); // 推荐按字段路径更新 this.setData({ gameInfo.players: newPlayers, gameInfo.state: night, timer: 10 });推荐写法里的数据路径是字符串路径的key不能包含数字数组下标要写成array[0]的形式。全量更新时即使state和timer没变也会触发渲染层diff所有字段造成不必要的性能损耗。对于每秒更新一次的倒计时最好单独用一个timer字段更新不要和玩家列表混在一起。5.2 列表渲染的key选择玩家列表是游戏页面里最常出现的列表。列表项包含头像和名字且玩家状态会变存活、死亡、已投票所以wx:key必须使用稳定唯一值比如playerId。不要用index因为数组重排时index会变化导致渲染出来的图片和文字错位。看一段正确写法view wx:for{{playerList}} wx:keyplayerId image src{{item.avatar}} modeaspectFill/ text{{item.name}}/text /view另外要注意wx:if和hidden的取舍。wx:if是惰性渲染初始为false时不会创建节点适合夜晚行动这类低频切换hidden只是display:none节点始终存在适合随时可能要显示、隐藏的聊天输入框。狼人杀夜晚到白天的切换一天最多几次所以用wx:if更省内存。5.3 组件化复用从template升级到Component前面提到titlebar这类纯展示用template但带交互的actionArea建议做成Component。自定义组件的好处是内部逻辑封装父页面不需要关心按钮样式只用监听事件。看一个简易实现// components/actionArea/actionArea.js Component({ properties: { items: { type: Array, value: [] } }, methods: { onItemTap(e) { const { action } e.currentTarget.dataset; this.triggerEvent(actiontap, { action }); } } });对应的WXML渲染按钮并绑定data-action父页面这样使用action-area items{{actionItems}} bind:actiontaponActionTap/在父页面里通过event.detail.action拿到具体动作再调用engine里对应的方法。自定义组件和template相比数据隔离更好组件内部setData不会触发父页面重渲染。尤其是actionArea在游戏过程中被频繁更新按钮状态用组件可以有效缩小渲染范围。5.4 真机调试与常见坑微信开发者工具模拟器和真机渲染机制差异很大尤其是在IOS上。狼人杀涉及长按拖拽选择目标touchmove事件在IOS上比Android灵敏如果页面同时存在scroll-view很容易出现拖拽时页面跟着滚动的bug。解决方法是给承载拖拽的view加catchtouchmovenoop阻止事件向上冒泡。真机调试时打开vConsole可以看日志和控制台报错但vConsole会遮挡屏幕测试完记得关闭。另一个常见问题是开发工具上正常的wx.getStorageSync在真机上偶尔失效建议关键数据用云函数或globalData兜底。下面这张表汇总了本节提到的优化手段优化手段操作减少setData用数据路径更新局部字段列表渲染用playerId做wx:key不用index组件化拆分template改为ComponenttriggerEvent真机调试开vConsole测完关闭6. 状态机驱动流程的边界与EventBus解耦的踩坑记录有些状态不适合放进全局状态机里比如女巫的解药和毒药次数。如果把“解药已用”放进engine.state你会得到night_witch_nopotion、night_witch_noposion、night_witch_dead这样的组合爆炸状态。正确做法是把这类一次性技能状态放到player对象上用antidoteUsed和poisonUsed布尔值状态机只管当前是哪个阶段。每次进入女巫阶段前判断这两个布尔值如果都没有就跳过NIGHT_WITCH直接进入NIGHT_SEER。EventBus的另一个坑是页面生命周期错位。比如狼人页面onLoad时注册监听之后用户切后台微信可能在60秒后回收页面并触发onUnload等用户回到前台又会重新创建页面导致监听重复注册。修复办法是在onLoad里先off再on但也别小看off的时机——如果先emit再on事件就丢了。所以engine.transitTo的emit调用最好放在wx.nextTick里确保监听方页面onLoad已经执行完。我在项目里通常让页面在onReady后再去拉取当前状态避免错过初始事件。最后给一个具体技巧角色配置不要直接写入代码而是放在一个JSON文件里启动时从云函数拉取。这样后续调整狼人数量、女巫药水数量不需要重新发布小程序。微信小程序不支持动态执行远程代码但数据可以远程更新。在开局前拉取一次配置然后固定在本局缓存中就能做到配置热更新。这个项目的本地配置已经比较完整生产环境通常还会加上版本号字段判断是否需要强制更新配置。如果你是第一次拆这份源码建议从roles.js和engine.js入手把黑夜顺序调一遍再考虑加网络层这样每一步的可验证性都很强。本文还有配套的精品资源点击获取