Spring Boot + MySQL 搭一个多人游戏后端:登录、房间、匹配、对局和成长系统
项目地址:
- GitHub:github.com/typsusan-zz...
- Gitee:gitee.com/susantyp/wi...
- 在线体验:wildhunt-backend-production.up.railway.app
很多人做 Web 游戏 Demo 时,会把重心放在前端画面和玩法上,后端只写几个临时接口。但 WildHunt 这个项目里,我希望后端也尽量像一个真实小型游戏服务:有游客和账号登录,有大厅,有房间,有快速匹配,有正式对局,有 WebSocket 实时消息,有玩家资料、排行榜、每日签到、活动和赛季通行证。它不是为了商业上线,而是为了把"全栈游戏 Demo"这条链路跑完整。
后端目录在 backend/,是一个多模块 Maven 项目:
text
backend/wildhunt-common/ 通用响应、枚举、异常、工具
backend/wildhunt-dal/ MyBatis-Plus 实体和 Mapper
backend/wildhunt-service/ 业务逻辑
backend/wildhunt-web/ Controller、WebSocket、配置和启动入口
技术栈是 Spring Boot 3、Java 17、MyBatis-Plus、MySQL、Flyway、Spring WebSocket、Spring Security、springdoc-openapi。这个拆分有一个好处:web 层只负责协议入口,核心逻辑放在 service,数据库访问集中到 dal。项目规模不大,但边界清楚,写文章或做简历项目时也更容易解释。
数据模型:先把游戏业务拆成几类表
数据库迁移放在 backend/wildhunt-web/src/main/resources/db/migration/。最早的 V1__init_schema.sql 建了基础表,包括:
wh_user:用户基础信息wh_user_auth_binding:第三方登录绑定wh_player_profile:玩家等级、经验、rating、称号等资料wh_friend_relation、wh_friend_request:好友关系和申请wh_room、wh_room_member、wh_room_invite:房间、成员、邀请wh_match_queue:匹配队列wh_game_match、wh_match_player、wh_match_event、wh_match_snapshot:对局、玩家结果、事件、快照wh_leaderboard_season、wh_leaderboard_entry:排行榜赛季和榜单wh_notification:通知wh_system_config:系统配置
后续迁移继续补充了房间类型、当前对局 ID、离房状态、匹配偏好角色、房间聊天、踢人日志、每日签到、活动、用户资产、赛季通行证等。比如 V2__room_match_login_refactor.sql 给 wh_room 增加 room_type 和 current_match_id,给 wh_match_queue 增加 preferred_role、matched_room_id、matched_match_id、matched_at、cancelled_at,还新建 wh_room_chat_message、wh_room_kick_log、wh_daily_checkin、wh_activity。
这种表结构的重点不是"表很多",而是把多人游戏常见的业务对象显式建模:玩家是谁,在哪个房间,是否准备,是否在匹配,当前对局是什么,胜负如何结算,成长系统如何记录。
登录:先保证每个连接都有身份
实时对局和房间消息都需要身份。项目里有 AuthService、UserService、JwtService,Controller 通过 HTTP Header 里的 Authorization 解析用户。为了降低体验门槛,项目支持游客登录;游客也会在数据库里有用户和玩家资料。这样就不需要用户先完成复杂注册,仍然可以参与房间、匹配和对局。
服务层大量使用 userService.getOrCreate(userId),这是一种适合 Demo 的容错方式:如果用户资料不存在,就补一份基础资料。对于真实生产项目,用户状态会更严格;但对技术 Demo 来说,这能让更多流程在本地和线上顺畅跑起来。
房间系统:自定义房间和系统房间共用一套模型
房间逻辑集中在 RoomService。房间分两类:
PLAYER:玩家自建房间,支持公开列表、邀请码、准备、开始、离开、踢人。SYSTEM:快速匹配创建的系统房间,通常创建后直接进入 PLAYING。
createRoom 会检查玩家是否已经在房间内,创建 MutableRoom,生成房间码,把房主加入成员列表,并持久化 wh_room、wh_room_member。房主默认 ready,角色先设为 WOLF。这里的角色只是房间阶段的默认显示,正式开始时会重新分配。
房间成员用 MutableMember 表示,里面有 userId、seatNo、owner、ready、role、leftAt、leaveReason。离开房间不是简单从列表删除,而是设置 leftAt 和 leaveReason,并在数据库里把成员置为非活跃。这一点很重要,因为真实业务里经常需要追踪谁离开了、是否被踢、房主是否转移。
开始游戏时,RoomService.start 会做几件事:
- 验证操作者是否房主。
- 检查非房主成员是否都已准备。
- 将房间状态改为 PLAYING。
- 用
new Random(room.id)从成员里选一个狼。 - 为每个成员生成
PlayerAssignment。 - 调用
gameMatchService.createMatch创建正式对局。 - 发布
GAME_START实时事件给每个成员,附带 matchId 和 assignedRole。
用 roomId 作为随机种子的好处是角色分配可复现,至少同一个房间开始时不会因为多个节点或多次调用产生不可解释的分歧。
快速匹配:先可玩,再逐步复杂化
快速匹配在 MatchmakingService。它维护了内存队列 waiting 和最近结果 lastResult,也会尽量写入 wh_match_queue。匹配入口是 enqueue(userId, queueType),支持 WOLF、DEER、AUTO。
这里的策略很实用:如果玩家选狼或自动,系统会优先找等待的鹿;如果能配对,就创建一个包含狼和鹿的系统房间;如果没有等待对象,就可以立即创建系统对局,允许 AI 补足另一方。鹿玩家也可以立即进入系统房间,由 AI 狼参与。这样做的目的很明确:Demo 不能卡在"等另一个真人上线"这一步。
匹配流程里还有一些业务保护:
- 同一个用户不能重复入队。
- 已在房间内不能匹配。
- 匹配和取消都有 RateLimit,防止高频点击刷接口。
- 队列状态会记录 WAITING、MATCHED、CANCELLED。
- 匹配成功后会写 matchedRoomId、matchedMatchId、matchedAt。
对技术分享来说,这里可以强调一个思路:早期多人游戏 Demo 不一定要马上做复杂 Elo 匹配、分区匹配、延迟匹配。先保证"点了匹配就能进对局",然后再逐步加入公平性和等待策略。
对局服务:MatchSnapshot 和 RuntimeMatch
正式对局由 GameMatchService 管。它同时维护三个层次:
matches:matchId 到MatchSnapshot的映射,表示对局元信息和玩家列表。currentMatchByUser:userId 到当前 matchId,支持恢复当前对局。runtimeMatches:matchId 到RuntimeMatch,表示服务端实时运行状态。
createMatch 会生成 matchId 和 matchSeed,把真人玩家、AI 狼、AI 鹿都放进 players 列表。若没有真人狼,就添加一个 AI Wolf;再根据 aiDeerCount 添加 AI Deer。然后根据玩家分配和配置生成 MatchSnapshot,创建 RuntimeMatch,并持久化 wh_game_match 和 wh_match_player。
RuntimeMatch 是后端对局权威状态的核心。它保存 RuntimePlayer 和 RuntimeDecoy。RuntimePlayer 有位置、朝向、角色、是否 AI、是否死亡、是否使用过分身等。RuntimeDecoy 则表示鹿释放出来的分身,有 ownerUserId、位置、方向、速度、过期时间。每次前端通过 WebSocket 上报输入,GameWebSocketHandler 会调用:
java
GameRealtimeUpdate update = gameMatchService.applyInput(matchId, userId, input);
applyInput 内部会找到 RuntimeMatch,并在 synchronized(runtime) 中推进状态。这种 per-match 锁很简单,但对单机部署和轻量 Demo 足够可靠。它避免同一局多个玩家同时输入导致状态竞争,也不会把所有对局锁在一个全局对象上。
结算:胜负、经验、奖杯和赛季通行证
对局结束时,settle(matchId, wolfWin) 负责结算。它用 settleLocks 防止重复结算。对每个真人玩家,根据角色和 wolfWin 判断是否胜利,胜利给 60 经验和 30 奖杯,失败给 20 经验和 -10 奖杯。随后调用:
userService.recordMatch(...)更新玩家资料。seasonPassService.addExp(...)推进赛季通行证。presenceService.clearPlaying(...)清理在线状态。persistPlayerResult(...)更新wh_match_player的结果、经验和奖杯变化。persistMatchStatus(...)把对局改成 FINISHED。
这套结算逻辑说明后端不只是"转发 WebSocket 消息",它还承担了游戏业务闭环:一局对局会影响玩家资料、排行榜和成长系统。即使前端只是 Demo,后端也能体现真实产品会遇到的业务关系。
实时消息:房间、游戏、大厅分通道
WebSocket handler 在 wildhunt-web/src/main/java/com/wildhunt/web/ws/ 下,分为:
RoomWebSocketHandlerLobbyWebSocketHandlerGameWebSocketHandler
注册在 WebSocketConfig:
java
registry.addHandler(roomHandler, "/ws/room").setAllowedOriginPatterns("*");
registry.addHandler(gameHandler, "/ws/game").setAllowedOriginPatterns("*");
registry.addHandler(lobbyHandler, "/ws/lobby").setAllowedOriginPatterns("*");
分通道的好处是边界清楚。大厅只关心在线状态、好友、通知;房间关心成员、聊天、准备、开始;游戏关心输入和快照。如果全塞在一个 /ws 下,也能做,但消息类型会越来越混乱。
GameWebSocketHandler 在连接时校验 token 和当前对局,立即发送首帧 GAME_SNAPSHOT。收到 PLAYER_INPUT 后检查 seq,丢弃旧包,然后调用 GameMatchService.applyInput,再广播快照。这里没有使用外部消息队列,意味着当前更适合单实例部署。README 里也明确当前基于免费资源部署,线上可能较慢,推荐本地体验。
本地开发兜底:有数据库更完整,没数据库也能跑部分逻辑
项目里很多持久化方法都有 try-catch,Mapper 为空或数据库异常时会静默降级,比如 persistMatch、persistPlayers、persistRoom。这不是生产最佳实践,但对开源 Demo 很友好。因为读者可能只是想跑一下前端场景或本地流程,不一定马上配置 MySQL。
同时项目也提供了标准数据库路径:MySQL 8 + Flyway。application.yml 里 datasource 从环境变量读取:
yaml
spring:
datasource:
url: ${MYSQL_URL:jdbc:mysql://localhost:3306/wildhunt?...}
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:123456}
flyway:
enabled: ${SPRING_FLYWAY_ENABLED:false}
locations: classpath:db/migration
如果从空库开始,可以设置 SPRING_FLYWAY_ENABLED=true,让 Flyway 自动迁移。仓库还提供了 backend/docs/init_sql/wildhunt.sql,方便快速恢复一份完整本地库结构。
小结
WildHunt 的后端并不复杂,但它把多人游戏常见的业务链路都放进来了:身份、房间、匹配、对局、实时同步、结算、成长、排行榜、活动、签到。对技术分享来说,这比单纯展示一个 Three.js 场景更有价值,因为它证明了一个 Web 游戏 Demo 也可以用常规全栈工程方式组织起来。
如果继续演进,我会考虑三件事:第一,把 RuntimeMatch 从内存状态抽象成可插拔运行时,方便未来接 Redis 或独立游戏服;第二,把 WebSocket 广播从单实例内存 session 升级为可多实例分发;第三,把对局事件真正写入 wh_match_event,让复盘、观战和问题排查更方便。即便现在只是技术 Demo,这些扩展方向也能自然接上当前架构。