资讯动态

Java WebSocket实战:手把手教你实现《谁是卧底》多人实时游戏

发布时间:2026/10/14 4:36:38 来源:尧图企业网站定制
聊聊这个项目做了这么多年 Java 后端我一直觉得最练手的方式就是做几个带实时交互的小游戏。《谁是卧底》是我在某个阶段给自己定下的练习项目正好把 JavaWeb 的整套知识串了起来Servlet、WebSocket、线程管理、JSON 协议、前端状态同步一个都不少。它看起来是个小游戏实际上是一个典型的多人实时交互系统和你平时做的聊天室、协同编辑、在线答题底层逻辑没有本质区别都是很多人同时在线、状态需要对外一致、消息要低延迟。如果你正想找项目来做或者正在准备毕业设计这个项目的覆盖面很合适它不依赖各种复杂组件一个标准的 Web 容器 一套 WebSocket API 一个简单的数据库或者直接放内存就能跑出完整成型的多人游戏。做出来以后你对 Java 后端服务模型、HTTP 和长连接的区别、前端怎么维护一套游戏状态都会有非常直观的体感。这篇就把我怎么设计、怎么实现、踩过哪些坑原原本本写下来你照着做就能跑通。1. 整体设计怎么把线下游戏搬进浏览器1.1 核心规则搞清楚这个游戏到底在做什么《谁是卧底》的规则大家应该都知道每位玩家手里分到一个词语绝大多数人拿到的是同一个词平民词少数人拿到的是另一个相近的词卧底词。游戏轮流发言每轮结束后大家投票得票最多的玩家出局。当卧底都被投出去平民胜利当卧底人数追平民人数卧底胜利。这个规则线下玩非常简单因为有一名主持人线下发牌、控制节奏。但做成网络版我们会面临一个问题主持人的工作谁来做我的做法是让系统自动当主持人。系统负责在每局开始前给玩家发放词语、告知每一轮该谁发言、组织投票流程、判定胜负并公布结果。整个游戏的过程是回合制的不是实时动作游戏所以它并不要求毫秒级响应但还是需要服务器主动向所有玩家推送状态更新这一点就决定了我们不能单纯用 HTTP 请求去做轮询而是要走 WebSocket 长连接。1.2 技术架构为什么选 JavaWeb WebSocket因为这是一个 Web 项目而且我希望它尽可能简单直接地跑起来所以核心选型是服务端语言JavaWeb 层用原生的 Servlet WebSocket 接口来做也可以在这个基础上套 Spring Boot 框架逻辑会更省心前端普通 HTML CSS JavaScript不需要上 Vue/React这能把原理讲得更清楚数据库核心游戏过程不落数据库所有临时状态放在服务器内存里。唯一可存的是玩家的昵称和战绩用 SQLite 或者一个简单的 Map 都能解决。为什么游戏过程不落数据库因为这类回合制游戏的状态是短命的一局不过几分钟落库会引入大量额外的读写开销和事务复杂度。玩家名和分数这类需要持久化的东西才进存储。这也是很多实时系统的通用做法。WebSocket 是这里边的关键。它和 HTTP 的关系你可以类比成打电话和发短信HTTP 每一次业务操作都要重新拨号、说话、挂断而 WebSocket 一旦建立连接双方就能持续通话任何一方随时可以说话。游戏里系统要随时通知玩家轮到你发言了现在进入投票阶段如果用 HTTP 轮询玩家每隔几秒请求一次服务端压力大不说消息还是延迟的用 WebSocket服务器可以把消息瞬间推给所有玩家。1.3 模块划分把项目拆成逻辑清晰的小块我当时的项目目录是这样划分的src/main/java/com/example/whoisthespy/ ├── config/ // WebSocket 配置类 ├── model/ // 玩家、房间、游戏状态等实体类 ├── websocket/ // WebSocket 端点和消息处理 ├── service/ // 游戏逻辑核心房管理、游戏流程控制 └── web/ // 普通 HTTP 控制器用于前端页面访问这样分模块的好处是逻辑解耦WebSocket 端点只负责接收和发送消息具体的游戏流程判断全丢给 service 层去处理。如果你以后想换前端框架或者把通信层换成 Socket.IO、Netty只需替换 websocket 包游戏逻辑完全不用动。我建议你也这样分哪怕需求看起来很简单因为真实开发里最难缠的就是各种逻辑互相纠缠尤其是当你后期要加逾期不发言自动跳过断线重连这些功能时模块划分清晰会让你省很多事。2. 游戏流程设计把一个完整的回合串出来2.1 房间与匹配机制游戏不能所有人挤在同一场里混乱发言得先有房间的概念。我设计了房间实体核心字段如下class Room { String roomId; // 房间号 ListPlayer players; // 房间内玩家列表 int maxPlayers; // 最多几人我默认设 8 人可调整 int minPlayers; // 至少几人才能开局我设置 4 人 GameState state; // 当前游戏状态WAITING / PLAYING / ENDED MapString, String playerWords; // 玩家ID - 发放的词语 RoundInfo round; // 当前回合信息 }房间号很重要我用的方案是 6 位数字逻辑也简单每次新建房间的时候生成一个不重复的随机数。玩家可以通过房间号加入指定房间。为什么要设最低人数因为人数太少游戏没意思而且卧底和平民的比例不好控制。我在实践中发现5-8 人的房间游戏体验最好所以我建议你最低限制 4 人低于 4 人时点击开房就提示人数不足2.2 游戏状态机从等待到结束的流转这个项目的核心其实是一台有限状态机。未来你写任何游戏类项目都会用到这个思路。整个流程的状态流转是WAITING - PLAYING房主点击开始 PLAYING - PLAYING每轮循环发言 - 投票 - 淘汰 - 进入下一轮 PLAYING - ENDED判断出胜负 ENDED - WAITING再来一局用状态机最大的好处是状态之间是清晰的不会出现非法跳转。比如 WATING 状态下绝对不允许执行投票操作因为玩家人都没齐呢。我专门写了一个方法做状态校验任何消息进来先检查当前状态匹不匹配不匹配直接忽略并返回错误提示。这是避免并发场景下各种诡异 bug 的保命策略。2.3 回合循环的细分节点每一轮游戏要经过这么几个节点我把每个节点都定义成单个消息类型系统发给所有人本回合开始XX 玩家发言该玩家发言前端把发言内容提交给服务器服务器转发给房间内所有人发言结束我设定一个时间比如 30 秒时间到自动进入下一位所有人发言完毕后进入投票阶段投票阶段每人选择要投的人投票结束统计票数公布被投出者身份是卧底还是平民该玩家出局系统判断胜负不合格就进入下一回合这些节点的实现方式我下面讲通信协议时一起说。这里你先记住一个核心思想任何一条消息从客户端到服务器再从服务器到客户端都要经过统一的协议格式否则系统就会变成一团乱麻。3. 核心通信协议客户端和服务器之间怎么说话3.1 消息格式设计因为所有数据都要走 WebSocket 通道我们得规定一种双方都能理解的暗号。我选择了 JSON这是 Web 世界通用的语言前后端解析都极其方便。每条消息我规定为{ type: 消息类型, data: { ...具体数据... } }type字段决定了这条消息要干什么。我定义了这样的类型集合joinRoom // 加入房间 createRoom // 创建房间 startGame // 开始游戏 speak // 玩家发言提交 vote // 玩家投票 roomMessage // 房间内聊天可复用 systemNotice // 系统提示 gameOver // 游戏结束通知为什么需要 type因为服务端接到一条消息后要能快速分发到不同的处理方法。你可以把 type 理解成 HTTP 里的 URL每一类操作对应一个业务接口。用 if-else 链还是 switch 都可以Java 17 以上你可以放心用 switch 模式匹配写起来很舒服。3.2 服务端 WebSocket 端点的实现方式我用的是 Java EE 标准的ServerEndpoint注解方式它能让你直接通过内部后端的接口发布 WebSocket 服务。ServerEndpoint(value /ws/room/{roomId}) public class GameWebSocket { private Session session; private String playerId; private String roomId; OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { this.session session; this.roomId roomId; // 注册到 SessionManager } OnMessage public void onMessage(String message, Session session) { // 解析 JSON分发处理 } OnClose public void onClose() { // 从房间移除玩家 } }这里有一个非常关键的点每个 WebSocket 连接在服务端都对应一个 Endpoint 实例也就是说每个玩家都有独立的、属于他自己的 Session 对象。你不能用静态变量去存当前玩家你一定要把每个连接归属于它自己的玩家身份。我的方案是建立了一个SessionManager单例里面有一个 ConcurrentHashMap 保存 playerId 到 WebSocket Session 的映射然后按照房间维度维护另一个映射roomId - List 。具体来说public class SessionManager { // 玩家的 session 映射 private static ConcurrentHashMapString, Session playerSessions new ConcurrentHashMap(); // 房间内玩家的列表 private static ConcurrentHashMapString, ListString roomPlayers new ConcurrentHashMap(); }所有发消息的方法本质上就干这么一件事拿着玩家ID找到 Session然后session.getBasicRemote().sendText(json)。如果你要广播给整个房间就遍历房间的玩家列表逐个发送。提示真实项目里你别用静态方法管这种全局状态后续还要考虑并发把 SessionManager 设计成单例模式会更规范。我这里为了演示简洁使用了静态方式但你要意识到这是个设计精简。3.3 消息路由与服务端分发逻辑服务端收到一条 JSON 消息第一步总是解析type第二步是拿着data里的字段去做业务处理。我写了个路由器方法大致逻辑是public void routeMessage(String rawMessage) { JsonObject json JsonParser.parseString(rawMessage).getAsJsonObject(); String type json.get(type).getAsString(); JsonObject data json.getAsJsonObject(data); switch (type) { case createRoom: handleCreateRoom(data); break; case joinRoom: handleJoinRoom(data); break; case startGame: handleStartGame(data); break; case speak: handleSpeak(data); break; case vote: handleVote(data); break; default: sendError(session, 未知消息类型); } }你可能会觉得这套东西和写 Spring MVC 控制层有点像。对思路就是一样的只不过传输层从 HTTP 换成了 WebSocket你的请求从浏览器发出来的时候不是通过 URL 加参数了而是通过长连接发一段 JSON。掌握这套思路后以后换任何语言、任何框架做实时应用都是同一套逻辑。4. 游戏逻辑核心这些坑我替你踩过了4.1 玩家身份分配怎么做到不重复、不泄露身份分配是游戏逻辑里最容易出问题的地方。首先系统从词库中随机挑选一条词对词对里包含两个词平民词和卧底词。然后随机挑选少数玩家当卧底其他人当平民。平民收到平民词卧底收到卧底词。我的实现是这样的public void assignWords(Room room) { // 词库中随机挑选词对 WordPair pair WordLibrary.getRandomPair(); ListString playerIds room.getPlayerIds(); Collections.shuffle(playerIds); int spyCount calculateSpyCount(playerIds.size()); // 前 spyCount 个玩家是卧底 for (int i 0; i playerIds.size(); i) { String word (i spyCount) ? pair.getSpyWord() : pair.getCivilianWord(); room.playerWords.put(playerIds.get(i), word); } }calculateSpyCount我这里按经验定5 人游戏是 1 个卧底6-7 人是 1 个卧底8 人游戏是 2 个卧底。卧底太多容易快速暴露太少则卧底压力太大这个比例玩起来体感比较平衡。然后最重要的词语绝不能经由服务端广播给所有玩家平民词和卧底词只发给各自的持有者。这里最容易犯的错误是拿到发言人权限后为了省事把整套玩家数据广播出去结果所有人都看到了别人的身份词。我建议给每个玩家发消息时做一次过滤只发该玩家自己的词语杜绝全量广播身份数据这种蠢办法。经验教训就是消息越精简越好别动不动把整个对象树发给客户端。4.2 并发安全玩家同时发言和投票会导致数据错乱吗这里我想重点聊聊并发因为这类游戏极其容易出现并发问题。场景两个玩家同时提交投票服务器在并发地处理两条消息如果房间对象里的一个 List 被同时读写就会抛 ConcurrentModificationException或者票数统计对不上。我的建议是在房间维度上加锁具体做法是给每个房间配备一个锁对象任何修改房间状态的操作都走同步块public void handleVote(String roomId, String voterId, String targetId) { Room room RoomManager.getRoom(roomId); synchronized (room) { // 校验状态、记录投票 // 判断是否所有人都投完票了 } }为什么不锁整个JsonObject或者锁全局因为不同房间之间互不影响你要是加一把全局锁玩家数量一多性能就废了。同步到每个房间既保证了同一个房间内数据不会乱不同房间之间又能并行处理。另一个并发问题玩家同时点击开始游戏。两个玩家都点了开始如果代码里没有房间状态判断就会触发两次发牌。解决办法很简单在开始游戏的方法里先判断room.state WAITING然后立刻设为PLAYING再执行后面的发牌逻辑。由于这个操作在synchronized(room)块里另一个玩家再进来时状态已经变成 PLAYING 了就会直接被挡回去。这就是用状态机 同步锁防重入的思路。4.3 超时机制玩家断线或者不操作怎么办真实对局中一定会有人网络不好、有人中途退出、有人甚至关浏览器了。我在第一版里没做超时结果遇到过这种情况某个玩家掉线了但是他的 Session 还留在房间玩家列表里系统还在等他发言然后所有人都干等着一局游戏就这么卡死了。后来我加了几个策略心跳检测客户端每 10 秒发一次 ping服务端超过 30 秒没收到就判定玩家离线。离线替代轮到离线玩家发言时系统自动替他说该玩家掉线跳过发言。投降机制系统自动判断离线玩家人数如果超过一半直接终止游戏并且给所有存活在线玩家发系统消息。其实最简单的保底方案就是每一轮发言和投票都加上时间限制。我写了一个RoundTimeoutTimer它是一个 Java 的 ScheduledExecutorService当进入某个阶段时会启动当前阶段的定时任务超时后自动触发下一个阶段的逻辑。比如投票阶段倒计时 30 秒时间一到还没投票的玩家默认弃票然后立即统计票数进入结算。这个功能加完之后游戏的流畅性上升了一个量级。5. 房间管理和前端界面让玩家真正玩得起来5.1 创建与加入房间的完整流程前端页面就两个核心界面一个是房间大厅可以创建房间或输入房间号加入另一个是游戏界面。创建房间的流程是前端输入昵称点击创建房间前端发起 WebSocket 连接到/ws/room/新建的房间号连接成功后向前端发送一条 createRoom 消息服务端创建 Room 对象把当前玩家加为房主服务端广播给该玩家房间创建成功房间号为 xxx加入房间的流程类似前端输入昵称和房间号发起 WebSocket 连接发送 joinRoom 消息带上房间号服务端检查房间是否存在、人数是否已满、是否已经开始如果通过就把玩家加入该房间同时广播给房间内所有人XX 加入了房间有几个细节要处理房间号不存在时服务端要返回一个明确的错误类型房间已经开始时要提示该局已经开始人数已满要提示房间已满。前端要根据返回的 type 区分这些分支否则玩家体验很差。5.2 前端消息分发WebSocket 收件箱怎么设计前端部分我不推荐每个按钮都直接用ws.send()散弹枪式地发消息。更好的做法是把所有消息走一套统一封装function sendMessage(type, data) { ws.send(JSON.stringify({ type: type, data: data })); }而收到服务端消息时也要做统一分发ws.onmessage function(event) { const msg JSON.parse(event.data); switch (msg.type) { case systemNotice: showNotice(msg.data.message); break; case yourWord: setWord(msg.data.word); break; case speechRound: enterSpeechMode(msg.data); break; case voteRound: enterVoteMode(msg.data); break; case playerEliminated: handleEliminated(msg.data); break; case gameResult: showResult(msg.data); break; case error: showError(msg.data.message); break; } };这里我必须强调一个新手最容易犯的错误服务端主动推消息时前端必须能区分系统消息和游戏状态更新。比如轮到某玩家发言时服务端推给所有人的应该是XXX 正在发言而推给该玩家本人的应该多带一个是你要发言的标记。我当时做的时候一个场景没区分好结果所有玩家都弹出了轮到你发言的输入框场面非常混乱。后来我统一约定消息里带一个isSelf字段前端据此来决定是显示发言输入框还是仅显示他人发言状态。5.3 词库设计词库质量决定游戏体验你可能不会想到词库在这个项目里占了很大的重要性。同一对近义词的质量直接决定了这一局好不好玩。我在前期随便写了十几个词对结果玩起来几局就腻了而且有些词对相似度太低卧底一开口就暴露了有些词对又相似度太高平民根本不知道卧底在讲什么。我后来建了一个词对结构{ civilianWord: 苹果, spyWord: 梨, difficulty: 3 }并且把词库存成 JSON 文件放到 resources 目录下方便随时增删调优。加载时用随机索引取词对就算同一局碰到相同词对也不会有人说又来了因为每次搭配的玩家都不一样玩起来的感受完全不同。词对数量至少准备 50 组以上才能有持续的体验这是线下聚会的经验照搬过来的。6. 部署上线从本地跑到局域网可联机6.1 打包方式我最后是把项目打包成 war 包部署到 Tomcat 上这样最省事。如果你用 Spring Boot也可以打成 jar 包跑内嵌 Tomcat。我用 Maven 管理依赖核心依赖只有dependency groupIdjakarta.websocket/groupId artifactIdjakarta.websocket-api/artifactId version2.1.0/version scopeprovided/scope /dependency注意最后一个 scope 是 provided因为 Tomcat 本身已经带了 WebSocket 实现打得包里再塞一份反而可能起冲突。如果你跟着写大概率会踩一个很经典的坑直接运行 main 或者用 IDE 内置 Tomcat 时 WebSocket 可以连接但部署到外置 Tomcat 就 404。原因是 WebSocket 端点没有扫描注册成功或者扫描包路径写的不是你那个包。我后来在配置类里加了显式注册把 Endpoint 像 Servlet 一样注册进去问题就消失了。这算是最典型的 WebSocket 部署坑之一。6.2 局域网联机测试开发完以后我先在本机用两个浏览器窗口测试一个开房间一个加入游戏这样方便看到双方收到的消息差异。随后我让另一台机器连进来用局域网 IP 而不是 localhost结果遇到了浏览器拦截的问题。Chrome 对非安全的 WebSocket 有严格的权限限制如果用 HTTP 访问页面WebSocket 连接只能用ws://不能用wss://。这个坑虽然低级但团队里真的有人卡了很久。另外如果服务器机器开了防火墙一定要放行 8080 端口否则外网机器连不上。我在某个服务器上跑了 Tomcat默认端口 8080 没放行结果自己本机测试时好好的别人一连接就超时排查到最后才反应过来是防火墙。7. 常见问题与排查技巧实录7.1 玩家掉线/刷新页面后怎么处理WebSocket 断开的场景比你想的要多手机切网络、电脑休眠、浏览器被后台回收等等。玩家没有主动退出游戏就断线了我们要做两件事一是清理资源从 SessionManager 移除 Session、玩家状态置为离线、广播给其他人。二是处理库存状态如果断线时是发言阶段系统直接跳到下一个人如果断线时是投票阶段系统自动给它记弃票。这个逻辑我已经在前面超时机制里提过但这里再强调一次断线恢复和超时触碰的是同一套转移逻辑要把强制过这套逻辑做成公共方法避免以后改一处漏另一处。7.2 消息重复发送前端发了两次服务端处理两次浏览器网络状态不稳定时WebSocket 消息可能重发客户端也可能因为按钮没禁用而连点提交。结果是一个发言消息被处理两遍在聊天区域显示两条一个投票被记两次票数翻倍。我的解决方案是双保险前端提交按钮在点击后立即置灰等服务器确认后再恢复或直接不恢复。后端同一房间同一阶段对同一个玩家的行为做去重。每个玩家在投票阶段有一个hasVoted布尔值再次收到他的投票消息时直接忽略。if (player.hasVoted) { return; // 忽略重复消息 } player.hasVoted true;这套去重逻辑在现实开发里是标配不仅仅是游戏比如你以后做支付回调、做表单提交都要在服务端做幂等。7.3 并发修改异常快速定位路线如果你碰到了ConcurrentModificationException或者java.lang.IllegalStateException: The remote endpoint was in state [TEXT_PARTIAL_WRITING]这通常表示有两条线程同时操作同一个 WebSocket Session比如广播消息的线程和处理心跳的线程同时调用sendText。网上有各种解决方案比如加锁、用队列串行发送消息。我最终采用的是为每个 Session 单独维护一个发送队列或用 ConcurrentHashMap sendObject的同步写模式保证同一时间只有一条线程在调用sendText。注意session.getBasicRemote()不是线程安全的你得自己保证不要并发调用它。如果你觉得这个方案太复杂还有一个简单粗暴但有效的方法把同一个房间内所有发送动作也用房间锁包起来。房间内人数最多 8 人这个锁的竞争不会对性能产生什么影响但能保证广播消息不会交叠。7.4 调试技巧用浏览器开发者工具抓 WebSocket 帧我强烈建议你调试时打开 Chrome 的 F12 开发者工具切到 Network 面板找到 WS 标签页你会发现每个 WebSocket 消息都以帧的形式显示出来包括发送方向和消息内容。这比做任何日志输出都直观得多。我通常的调试思路是打开两个浏览器窗口同时进入同一房间一个窗口操作发言、投票另一个窗口看 WS 帧如果服务端逻辑处理有问题立刻可以在帧里看到错误的返回消息比如有时前端收不到服务端发送的词在 F12 里一看服务端压根没发那问题定位在后端逻辑上如果服务端发了但前端 JS 解析出错那就是我在onmessage里的 case 分支写错了。这类问题都能用这个办法十分钟内定位完。8. 复盘与扩展这个项目还能做什么做这个项目的最大收获是你把完整的网络应用开发链路全走了一遍。从房间管理到状态机从 WebSocket 长连接到消息协议从并发控制到断线重连这些都是真实项目里每天都在用的知识。网上很多教程只教了 CRUD这个项目却逼着你去处理真正的状态和同步问题含金量很高。做完基础版之后扩展方向也很明确加一个简单的房间列表页让玩家可以浏览所有 WAITING 状态的房间不用手动输入房间号把词库做成后台管理界面可以提交新的词对社区化运营给游戏加上 AI 托管玩家掉线后由程序自动发言如果想让前端更舒服可以用 Vue 或 React 重新做界面后端不变因为协议已经固定了把单服务器改造为多服务器部署使用 Redis 管理房间状态这样就能支持跨服匹配甚至可以在前端引入音频接口让玩家可以语音聊天但这涉及音视频流复杂度会大幅提升建议量力而行我个人在实际做这个项目的时候最大的体会是网络游戏没有想象的那么难它难在面对的是多人同时操作同一份状态只要你想清楚状态的归属、消息的流向、异常的处理剩下的就是填充具体业务代码的问题。把《谁是卧底》打通了以后你再做任何实时协作系统心里都会有一张清晰的地图。最后分享一个写代码时的小习惯把每一个消息类型的处理方法都写成独立方法且加上注释说明谁在什么阶段会收到这条消息触发后应该做什么。这套注释一开始写会觉得啰嗦但过一周你再回来看代码会觉得当时的自己太明智了。我希望你做完这个项目也能收获同样的体验。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑