JavaWeb实战:基于WebSocket的谁是卧底在线游戏开发复盘
发布时间:2026/10/16 20:15:52 锦皓数字建站

朋友聚会玩“谁是卧底”最烦的不是有人拿词太离谱而是记票靠纸、数票靠喊、平票争论全靠嗓门。我干脆用 JavaWeb 做了一版网络游戏把发牌、描述、投票、结算全搬到浏览器里大家手机扫码进房就能玩。这篇内容就是把整个项目从规则拆解、技术选型到核心代码、踩坑过程完整复盘一遍适合正在学 JavaWeb 想拿完整项目练手的人也适合对 WebSocket 实时状态同步感兴趣的开发者参考。1. 谁是卧底规则的程序化拆解做游戏项目之前第一件事永远不是写代码而是把规则翻译成程序能理解的东西。谁是卧底这游戏线下玩起来流程似乎很清晰但真到了要写状态机的时候你会发现很多规则细节全是模糊地带。1.1 规则细节决定程序边界“谁是卧底”的标准玩法玩家中绝大多数人拿到同一个词平民词少数人拿到意思接近但不同的词卧底词。每轮所有人用一句话描述自己看到的词不能直接说出词本身描述要似是而非既能让平民确认彼此身份又不能暴露关键信息给卧底。每轮描述完投票票数最多的人出局游戏继续直到卧底被全部找出或者卧底活到只剩两人卧底胜利。但在程序里这些描述都是自由文本怎么判断谁像卧底答案是游戏本身不做智能判断判断权完全在人。程序要做的是保证信息不泄露、流程不卡死、投票统计无争议。比如“不能直接说出词本身”——程序很难判断玩家描述里是不是包含关键词但可以做最基础的字符串包含检查再比如“票数最多的人出局”——平票怎么办规则没说程序必须规定。这些边界在我动手前全部列出来变成了一条条硬性规则描述文本中不允许出现本局的平民词或卧底词包含这个词直接判为违规。平票时当前轮无人出局直接进入下一轮描述。卧底获胜条件卧底存活人数 平民存活人数或在只剩两名玩家时卧底仍在场。玩家掉线超过 90 秒未重连自动标记为出局。线下大家口头协商就能解决的模糊情况在程序里必须有个确定答案否则就会出 bug 或者游戏卡死。1.2 从线下流程到状态机的转换整个游戏流程其实是一个经典的状态机开局等待 → 轮流描述 → 全员投票 → 公布出局 → 判定胜负 → 结束重置。我把每个阶段定义成一个明确的枚举值并用一个当前状态变量控制服务器的响应行为。任何一局游戏只会处于以下某个状态之一WAITING房间已创建玩家可加入房主可点击开始。DESCRIBING正在收集本轮的描述所有存活玩家提交完毕后进入投票。VOTING正在收集投票所有存活玩家投票完毕或超时后进行结算。SETTLING结算阶段服务器判定谁出局、是否终局然后广播结果。ENDED游戏结束展示卧底身份和词条房主可重新开局。这个状态机是整个服务器的地基我所有的消息处理入口第一步都是校验“当前状态是否允许这条消息”不允许就直接忽略。这个习惯帮我挡住了大部分非法操作——比如还没开局就投票、已经结束了还在提交描述。1.3 游戏胜负的三种判定路径胜负判定拆开看其实只有三种情况第一卧底阵营全部出局平民胜利这是最常见的情况。第二卧底阵营存活数量大于等于平民阵营存活数量卧底胜利——因为卧底一旦占据数量优势平民无论如何都无法通过投票淘汰卧底了。第三场上只剩两个人如果卧底还没出局卧底直接获胜因为只剩两个人时卧底闭着眼都知道谁是平民投票名存实亡。这个判定逻辑在每轮出局结算后执行一次代码很短但必须放在正确的位置。我放在了“计算出局者之后、广播下一轮之前”确保任何一轮结束后都会立即判断是否要终止游戏。2. 技术选型与项目初始化JavaWeb 项目做实时交互游戏最核心的决策不是用什么框架而是用什么方式做实时通信。这一节我把自己的选型思路完整说出来。2.1 实时性需求判断为什么 Polling 方案不可行最原始的方案是前端每隔两三秒请求一次服务器拉取房间最新状态也就是轮询。这个方案在我开发到一半时确实动过念头但很快放弃了。原因是“谁是卧底”这个游戏完全是状态驱动型的。玩家 A 提交描述后其他玩家必须尽快看到“A 已提交”的提示否则体验会非常割裂。用轮询的话状态更新最快要多等一个轮询周期而且服务器要承受每个玩家每秒一次的请求量。一个 8 人房间每人 2 秒轮询一次服务器每秒要处理 4 个请求——看起来不多但如果同时有 100 个房间在线每秒就是 400 个请求而且绝大多数请求拿到的数据根本没变过纯属浪费。轮询本质上是用请求次数换实时性请求太频繁服务器压力大请求太少又卡顿。而 WebSocket 是一旦建立连接就保持住服务器可以直接把消息推到客户端反向亦然。对于这种小游戏这是唯一体感自然的技术方案。2.2 Spring Boot WebSocket 的组合方式我用的是 Spring Boot 2.7 自带的 WebSocket 模块没有引入 STOMP 协议层。原因很简单这个游戏的消息交互模式是“服务器广播给全员”和“单点发送给指定玩家”STOMP 的订阅-发布模型对这类场景略重而且调试难度更大。原生 WebSocket 的TextWebSocketHandler已经足够所有消息用 JSON 字符串收发简单直接。依赖只需要一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency前端我用的是原生 JavaScript 的 WebSocket API没有引前端框架。游戏核心页面就一个管理四个界面区域房间状态区、词条展示区、描述输入框、投票面板。原生实现反而更直观省去了一堆构建配置。2.3 项目目录结构与关键类规划项目包结构是一名 JavaWeb 老手一眼能看懂的划分方式controllerHTTP 请求入口比如创建房间、查看房间列表。websocketWebSocket 端点和握手拦截器。room房间领域对象包括房间、玩家、游戏状态。game游戏流程控制包括状态机、词库、结算逻辑。message消息协议类定义各种请求和响应的 JSON 结构。这个划分在项目规模变大后我还能准确定位每个文件也方便后来扩展功能。比如我要加自定义词库只需要改 game 包下面的词库类要加观战模式改 room 包和 websocket 包即可。3. 房间与玩家会话管理设计Web 游戏和单机程序最大的区别在于会话管理。单机游戏没有“别人”这个概念而 Web 游戏里所有操作都发生在多人并发的上下文中会话和状态的隔离是重中之重的设计。3.1 房间对象与生命周期我设计了一个Room类来代表一个游戏房间它持有该房间内所有游戏状态public class Room { private String roomId; private MapString, Player players; private GameState state; private WordPair currentWords; private int round; private String ownerId; private long createTime; }房间的生命周期包括玩家输入昵称创建房间服务器生成一个唯一房间号其他玩家输入房间号加入游戏开始后所有玩家进入同一局游戏结束或玩家全部退出后房间销毁。房间号我用了四位数字为了方便口口相传但这引入了碰撞风险我用随机数加重复检查解决生成房间号时如果发现已被占用就重新生成。创建房间是一个 HTTP 接口部署在外网后我用 Nginx 做了 WebSocket 反向代理HTTP 和 WebSocket 共用同一个端口和域名路径这样玩家在手机浏览器里访问一个地址就能完成建房间和玩游戏全程。3.2 WebSocket 连接与玩家身份绑定玩家进入房间后页面会发起 WebSocket 连接。这里有一个关键问题WebSocket 建立连接时没有 Cookie 之类的东西服务器怎么知道这个连接对应哪个玩家我的做法是把玩家信息放在连接握手阶段。前端连接时带上一个房间号和玩家令牌const ws new WebSocket(ws://${location.host}/ws/room?roomId${roomId}token${token});服务器端的握手拦截器从 URL 参数中解析出这两个字段验证通过后把连接接入对应房间。此后所有通过这个连接发送来的消息服务器都默认它是该玩家发出的不再需要额外身份校验。这种方式的代价是 token 一旦泄露别人就能冒充但游戏场景本身没有敏感数据风险可接受。玩家对象里存了 WebSocket Session 的引用方便服务器往指定玩家发消息。掉线检测就依赖 Session 的状态。3.3 消息协议定义消息协议我设计成统一的 JSON 结构type字段区分消息类型data字段携带具体数据。客户端发送给服务器的消息消息类型方向数据字段CREATE_ROOM客户端→服务器玩家昵称JOIN_ROOM客户端→服务器房间号、玩家昵称START_GAME客户端→服务器无SUBMIT_DESC客户端→服务器描述文本VOTE客户端→服务器被投票玩家IDRESTART_GAME客户端→服务器无服务器主动推送给客户端的消息消息类型方向数据字段ROOM_JOINED服务器→客户端房间所有玩家信息GAME_STARTED服务器→客户端本局词条保密与角色ROUND_START服务器→客户端轮次、存活玩家列表DESC_SUBMITTED服务器→客户端已提交描述的玩家IDVOTE_RESULT服务器→客户端出局玩家ID、票数统计GAME_OVER服务器→客户端胜负方、卧底身份、词条PLAYER_LEFT服务器→客户端离线的玩家ID这个协议表在开发时贴在我屏幕边上前后端就是照着这张表开发的。消息格式统一后出 bug 的概率大幅下降因为消息类型变成了一个明确定义的枚举集合不会出现字段名不匹配这类低级错误。4. 核心玩法代码实现要点框架搭好后最核心的工作是游戏逻辑本身。这一节我把几个关键流程的实现方式写出来都是可以直接参照的写法。4.1 词条分发与卧底随机指定词库我准备了一组一组的词对比如“苹果 - 梨”、“出租车 - 网约车”、“咖啡 - 奶茶”。每一组词对里第一个词作为平民词第二个词作为卧底词两个词含义相近但不同足够让玩家发挥。开局时服务器随机选一组词对再随机选一个人当卧底然后给每个人单独发词。发词这个动作必须经过 WebSocket 单独推送不能走广播否则大家都看到别人的词了。给平民玩家推“平民词”给卧底玩家推“卧底词”其他信息一律不推。private void dealWords(Room room, WordPair pair, String spyId) { for (Player p : room.getPlayers()) { String word p.getId().equals(spyId) ? pair.spyWord : pair.civilWord; sendToPlayer(p, Message.build(DEAL_WORD, Map.of(word, word, role, p.getId().equals(spyId) ? SPY : CIVIL))); } }整个分发过程用的是单人通道就是确保每个玩家拿到的词只有自己和服务器知道。这里唯一的坑是如果某个玩家恰好此时掉线词条就发不出去游戏会卡在等待状态。我的解决办法见后面的踩坑部分。4.2 描述阶段的收集与超时处理描述阶段服务器维护一个ConcurrentHashMap记录本轮已提交描述的玩家 ID每当收到一条SUBMIT_DESC消息就把该玩家标记为已完成。当完成人数等于存活玩家数时进入投票阶段。所有描述都会广播给其他玩家但这个广播有一个例外把自己也广播了没意义而且公开所有描述时UI 上会把“谁说了什么”和玩家身份对应起来。所以我的逻辑是任何玩家提交描述后服务器把描述文本和说话人 ID 广播给除本人外的所有存活玩家。还有超时问题。玩家可能打了字不提交也可能直接切走。如果不处理超时游戏就永远等下去。我写了一个定时任务每轮描述阶段超过 120 秒未全员提交服务器自动把未提交玩家标记为弃权直接进入投票阶段。这个机制保证了游戏不会因为某个玩家挂机而永久卡死。4.3 投票统计与平票处理投票阶段的数据结构更简单一个ConcurrentHashMap记录每个投票者的选择。投票完毕或超时后遍历票数统计每个被投票玩家的得票数。统计出票数最高的玩家后需要处理平票情况。我规定平票时本轮无人出局直接进入下一轮。这个规则虽然和线下有点不同但程序处理起来干净利落。出局玩家要从存活列表移除但保留在房间玩家列表中这样他还能看游戏继续只是不能再发言和投票。这个“保留席位”的设计让旁观体验很顺滑也不会因为玩家被淘汰就断开连接导致一系列状态清理问题。4.4 胜负判定与房间重置每轮结算后马上执行终局判定private boolean checkGameOver(Room room) { int civilAlive (int) room.getPlayers().values().stream() .filter(p - p.isAlive() p.isSpy() false).count(); int spyAlive (int) room.getPlayers().values().stream() .filter(p - p.isAlive() p.isSpy()).count(); if (spyAlive 0) return true; if (spyAlive civilAlive) return true; if (civilAlive spyAlive 2 spyAlive 0) return true; return false; }游戏结束后服务器广播GAME_OVER消息带上卧底玩家 ID 和两个词条然后房间状态回到WAITING等待房主点击重新开局。重启局的实现就是把玩家的存活状态、本轮描述、投票记录全部清空再重新发一组词。5. 开发与实测中踩过的坑这个项目最有价值的不是我顺利写出来的部分而是那些运行起来才暴露的问题。以下三个坑是我真实遇到并且花了不少时间才排查清楚的。5.1 并发修改异常一个玩家的退出影响了所有玩家第一个 bug 出现在多人同时在线测试时。一个玩家提交描述后服务器广播全员、标记已完成、进入下一个流程——这一个流程里我多次遍历和修改了players这个 Map。玩家 A 和玩家 B 同时提交描述两个线程同时遍历 Map一个线程发广播另一个线程碰巧把某个玩家的标记改了直接抛ConcurrentModificationException。排查这个问题的过程比较折磨因为错误不是稳定出现的只在并发量高的时候冒出来。我的修复方案是给房间级别的操作加了一把锁任何修改房间状态的操作都先获取该房间的锁处理完再释放。锁的粒度是房间不影响其他房间的并发也不会造成全局性能瓶颈。Root cause: 多线程同时遍历和修改同一份玩家集合 Fix: 房间维度加锁保证每个房间内是串行处理这个坑提醒我一个重要原则Web 服务器的请求处理天然是多线程的共享可变状态的每一步操作都要有明确的并发控制策略。5.2 玩家掉线后的垃圾数据与房间卡死第二个坑更隐蔽。玩家玩到一半手机锁屏WebSocket 连接断开但我的玩家对象还留在房间 Map 里标记为存活。游戏进入等待描述阶段后其他玩家提交了描述这个掉线玩家永远不提交超时机制触发后弃权但下一轮可能又是他发言就再次等待。而且更麻烦的是掉线重连。玩家重新打开页面老的 WebSocket 已经断了新的连接进来时我拿不到任何身份令牌因为他之前连接时用的令牌是页面内存变量页面刷新就丢了。我的解决方案是双重机制玩家对象里记录 Session 的开关状态掉线后标记离线但不立即移除新页面重连时可以输房间号和昵称如果昵称和离线玩家匹配就恢复到原来的玩家对象上。同时设置离线总时长超过 90 秒就直接自动退房避免占着位置。5.3 描述文本的敏感词过滤与规则提示第三个问题是规则约束。玩家在描述框里直接输入了本局的关键词比如平民词是“苹果”有人直接输入“我爱吃苹果”。如果程序不拦这条描述就会被广播出去游戏直接失去悬念。我写了一个简单的过滤器在描述提交时检测文本是否包含本局的平民词或卧底词包含则拒绝提交并推送一个错误提示。但这里有个误判风险某个描述词恰好是关键词的单字比如“苹”和“果”会被拆开吗我的实现里只做完整字符串包含检查不做拆字匹配避免误伤正常句子。实际体验下来最有效的不是技术过滤而是在规则提示里明确写一句“描述中说出关键词将被禁止提交”大部分玩家就不会故意踩线。6. 打包部署与横向扩展思路项目做到能玩只是第一步能长期稳定跑起来、能被更多人顺畅使用才是 Web 项目真正考验人的地方。6.1 标准打包姿势与 WebSocket 反向代理Spring Boot 项目部署时最方便的方式是打成可执行 jarmvn clean package java -jar target/who-is-spy-1.0.jar注意 WebSocket 端点在部署到公网时如果前面有 Nginx需要显式配置 WebSocket 的 Upgrade 头否则连接会 400 或直接断开。我在 Nginx 配置里设了proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection upgrade这两个头缺一个 WebSocket 都握手不成功。服务器内存方面一个游戏房间大概占几十 KB 的内存一个 4 核 8G 的云主机跑几百个房间毫无压力。因为 WebSocket 是长连接主要瓶颈在文件描述符数量和网络带宽上内存反而是最不用担心的。6.2 房间清理机制游戏房间如果没人主动退出会一直在内存里占着。我写了一个定时清理任务每 5 分钟扫描一次所有房间满足以下条件的直接销毁房间状态清理策略房间内所有玩家已离线立即清理处于 WAITING 且超过 30 分钟无新玩家加入自动清理处于 ENDED 且超过 10 分钟无人点击重新开始自动清理游戏进行中但所有玩家掉线超过 90 秒自动清理这个定时任务在真实运行环境里非常重要之前没加的时候我测试机器上的房间越积越多一个下午内存就被堆满。加了清理机制后房间数始终稳定在一个健康范围内不再有隐患。6.3 从“能玩”走向“好玩”的扩展方向核心功能稳定后还有很多可以提升体验的方向。我的计划是先加自定义词库房主可以自行输入一组组词对比如针对特定聚会人群定制。然后是观战模式支持未参与游戏的玩家只读地旁观战局这对游戏传播场景很有帮助大家围观比直接参与更能带动氛围。另外还有个有意义的功能是 AI 补位。当房间人数不足时由服务器模拟一个玩家自动生成描述、随机投票让两个三个人也能开局。这个功能的实现难度主要在描述生成质量规则上可以让 AI 玩家的描述从预设的模糊句式中随机选择效果够用就好。技术层面如果后续用户量涨上去房间状态和玩家状态可以挪到 Redis 里让多台服务器共同服务同一个房间的连接请求但这属于比较靠后的优化现阶段单机完全够用。做这个项目的整个过程最大的体会是“规则即代码”。线下游戏里一句“票最多的出局”听上去很简单程序实现时平票、弃权、掉线、超时全冒出来想偷懒不处理任何一条之后都会变成线上事故。另一个体会是 WebSocket 游戏比 CRUD 项目有意思得多因为它逼你去思考并发、状态、异常恢复这些真实世界中一定会发生的事情。如果你也想练手我建议就按这个路径来先做单人控制台的流程模拟把状态流转跑通再加上 WebSocket 换成多人在线最后慢慢补异常场景。每一步踩的坑都会狠狠加深你对 JavaWeb 的理解比看十篇教程都管用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。