AI × 狼人杀:用 Spring Boot 3 + Vue 3 从零搭建全栈实时对战平台

作者:青云 发布时间: 2026-06-26 阅读量:63 评论数:0

一、为什么做这个项目

狼人杀是一个强实时、强状态、强信息隔离的多人博弈游戏。白天要发言、夜晚各角色要按顺序秘密行动、狼人要内部商量刀谁、女巫手握一瓶解药一瓶毒药、守卫不能连续守同一个人……这些规则天然对技术架构提出了苛刻的要求:

  • 实时性:任何人的行动都要毫秒级同步给全房间,倒计时必须全端一致;

  • 状态一致性:一个房间就是一台"状态机",多人在同一时间并发操作,不能出现重复结算、卡死、越权;

  • 信息隔离:预言家只能看到自己的查验结果,狼人只能看到狼队友,出局者的身份要等游戏结束才揭晓——错误推送等于作弊;

  • 真人不够怎么办:凑不齐 12 个人?让 AI 来当玩家,甚至让 AI 托管掉线的真人。

于是我用前后端分离的方式实现了这样一个「AI 狼人杀」全栈项目。本文不聊 UI 有多炫,只聊下面四个我觉得真正"硬核"的点:

  1. 双通道实时通信架构(Spring WebSocket + Netty 分工与降级);

  2. AI 玩家决策的工程化(Spring AI Alibaba + 通义/DashScope,怎么让它"会玩、不剧透、不卡顿");

  3. 内存 + Redis 双层的游戏状态设计(并发正确性与缓存生命周期);

  4. 一整套"做大项目才需要"的工程化基建(无状态 JWT、AOP 限流、国际化、链路追踪、成就补偿……)。

技术栈先亮出来:

端

技术

说明

后端

Java 17 + Spring Boot 3.5

基础框架

MyBatis-Plus 3.5 + MySQL 8

ORM / 主库(utf8mb4)

Redis 7 + Lua 脚本

缓存、在线状态、分布式互斥

Spring Security + JWT (jjwt 0.12)

无状态认证、自动续期、多端踢人

Spring AI Alibaba (DashScope)

AI 玩家决策、语音识别 ASR

Spring WebSocket + Netty 4.1

实时双通道

MapStruct / Hutool / Knife4j / Logstash

工程化配套

前端

Vue 3 + Vite 5 + Pinia + Vue Router

SPA

Element Plus + SCSS

组件与「赛博朋克」设计系统

Axios + 原生 WebSocket

HTTP / 实时通信

自定义 i18n

中英双语(含服务端消息翻译)


二、系统整体架构

先给一张"逻辑拓扑"(不是部署拓扑,是模块协作关系):

                         ┌──────────────────────────────┐
                         │        Vue 3 前端 (SPA)        │
                         │  PC/移动端自适应 · Pinia · i18n │
                         └──────┬───────────┬───────────┘
                        HTTP/JSON│           │WebSocket(wss)
                                 ▼           ▼
┌────────────────────────────────────────────────────────────┐
│                     Spring Boot 3.5 (8080)                   │
│  ┌──────────┐ ┌────────────┐ ┌─────────────┐ ┌───────────┐ │
│  │  REST API │ │ WS: /ws/game│ │ WS: /ws/chat│ │ /ws/netty │ │
│  │ 13 模块   │ │ /{roomCode} │ │   (IM)      │ │  (降级)    │ │
│  └──────────┘ └────────────┘ └─────────────┘ └───────────┘ │
│        │             │              │              │        │
│        ▼             ▼              ▼              ▼        │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  GameService / GameFlowService (状态机·结算·超时)      │    │
│  │  AiBehaviorService (AI 决策)  ·  EventSourcing       │    │
│  │  AchievementService · ImService · OnlineUserService  │    │
│  └──────────────┬───────────────────────┬──────────────┘    │
│                 ▼                       ▼                   │
│        ┌──────────────┐        ┌─────────────────┐          │
│        │  Netty :9000  │        │   Redis 7        │          │
│        │  全局事件推送   │        │  房间快照/在线/限流│          │
│        └──────────────┘        └─────────────────┘          │
└────────────────────────────────────────────────────────────┘
        │                                    │
        ▼                                    ▼
   MySQL 8 (元数据/战绩/IM/成就)        DashScope 通义千问/deepseek

2.1 "双通道"到底指什么

很多人一听到"WebSocket + Netty"会以为是灾备冗余,但这里其实是按消息类型分工 + 断线降级,共三条实时链路:

通道

路径 / 端口

职责

游戏 WebSocket

/ws/game/{roomCode}

房间内所有对局事件(玩家进出/阶段切换/行动/投票/发言)

聊天 WebSocket

/ws/chat

好友私聊、群聊、好友上下线

Netty TCP

:9000(原生 TCP)

全局事件广播(在线人数、大厅房间列表、通用推送);浏览器降级接入走 /ws/netty

三个端点全部注册在 WebSocketConfig 里,并用统一的握手拦截器做 JWT 认证:

registry.addHandler(gameWebSocketHandler, "/ws/game/{roomCode}")
        .addInterceptors(webSocketAuthInterceptor);
registry.addHandler(chatWebSocketHandler, "/ws/chat")
        .addInterceptors(webSocketAuthInterceptor);
registry.addHandler(nettyWebSocketHandler, "/ws/netty")
        .addInterceptors(webSocketAuthInterceptor);

会话由 WebSocketSessionManager 用三张 ConcurrentHashMap 维护,游戏会话与聊天会话刻意分离,避免同一用户同时开房间 + 聊天时互相覆盖:

  • gameSessions: userId → session

  • chatSessions: userId → session

  • roomUsers: roomCode → Set<userId>

为什么游戏/聊天要分离? 如果共用一个会话,用户一边挂在大厅/房间里、一边和好友聊天,后连接的一方会把前一个连接顶掉。分开之后各自独立,前端可以同时维持两条长连接。

2.2 消息协议长什么样

聊天与游戏共用一个带版本号的 JSON 协议,核心常量在 WsProtocol:

WsProtocol {
  version: "1.0.0"
  字段: version / type / data / timestamp
       / roomCode / senderId / senderName / requestId
}

WsMessageCodec.encode() 会自动补全 version 与 timestamp,序列化后下发;decode() 则会:

  1. 先做协议版本校验(只比较主版本号,向后兼容),不支持直接抛 WsProtocolException;

  2. 校验 type 非空;

  3. 对 data 做归一化——兼容"平铺字段"和"data 对象"两种形态,老客户端也能用;

  4. 可选 AES-GCM 加密:密文以 ENC: 头开头,IV 前置拼接,用于敏感消息。

消息类型集中在 WsMessageType,按域分组并统一 snake_case:

game_start / night_start / day_start / vote_start
seer_action / witch_action / guard_action / wolf_action
player_speak / hunter_shoot / game_end
chat_* / friend_* / group_* / voice_* / system_*

2.3 Netty 为什么要"另起炉灶"

Netty 通道解决两个 WebSocket 不方便的问题:

  1. 原生 TCP 客户端可接入(未来可做桌面/硬件客户端),通过 4 字节长度帧 + LengthFieldBasedFrameDecoder 解决粘包/拆包;

  2. 全局事件推送的卸载——WebSocket 端点都带 @Scope 语义,不适合做"给全平台广播在线人数"这类事情。

同时 Netty 做了很强的推拉结合:

  • sendEvent 会判断消息类型——聊天/群聊事件优先走 Chat WebSocket,WS 在线就不走 Netty 重复推,离线才降级,避免双通道重复消息;

  • sendEventToAll 广播时只预序列化一次 JSON,而不是对每个用户重复序列化;

  • broadcastOnlineCountUpdate 用 3 秒节流 + CAS 标记,防止连接抖动引发 O(N²) 的重复广播风暴。


三、难点一:让 AI 学会"打狼人杀"

这是整个项目最有意思的部分。狼人杀对 LLM 的要求不是"能聊",而是在有限信息下做决策、遵守回合、不剧透、不卡局。

3.1 AI 到底扮演什么角色

先说结论(避免过度吹嘘):AI 不做法官、不做主持人。发牌、天黑天亮播报、胜负判定全部是引擎的确定性逻辑 + 预置国际化系统消息(i18nKey),保证公平可审计。LLM 只出现在两处:

  1. AI 玩家:真人不足时加入(addAiPlayer),开局随机中文名、自动 ready、自动行动;

  2. 掉线托管(更妙的设计):真人中途掉线 → 后端标记 isEntrusted=true → 复用和 AI 玩家完全相同的自动决策管线(isAiOrEntrusted 统一判断)。真人回来可取消托管继续玩。

这意味着我只需要把"AI 行为"写一遍,就同时服务了"AI 玩家"和"真人托管"两个场景。

3.2 决策点全覆盖

AiBehaviorService 覆盖了狼人杀里 AI 需要的全部决策,且每个决策都有确定性兜底,保证 AI 永远不会因为"模型超时/输出不合法"而卡住整局游戏:

决策方法

说明

兜底策略

generateSpeech

白天发言

4 句模板随机

decideVoteTarget

白天投票

随机存活玩家

decideNightActionTarget

夜晚行动(按角色)

狼刀非狼队友 / 预言家验未查过的人 / 守卫守自己

shouldWitchHeal

女巫是否用解药

第一轮必救、之后不救

decideHunterShootTarget

猎人开枪

随机存活玩家

generateDaySummary

生成当日摘要

幂等:已存在则跳过

行动还会模拟"人类思考时间"(守卫 2.5~4.5s、狼 3~5.5s、预言家 3.5~5.5s、女巫 4.5~6.5s、发言 1~2.5s、投票 2~5s),让节奏更真实。

3.3 统一调用 + 超时重试

所有决策收敛到同一个 callLLM(),用 CompletableFuture + 15 秒硬超时包住同步调用,失败最多重试 2 次并指数退避——这是"在线游戏里调用 LLM"和"聊天框里调用 LLM"最大的区别:游戏等不起无限长的流式响应:

private String callLLM(String prompt) {
    try {
        return CompletableFuture
            .supplyAsync(() -> chatClient.prompt().user(prompt).call().content())
            .get(LLM_TIMEOUT_SECONDS, TimeUnit.SECONDS);   // 15s 硬超时
    } catch (Exception e) {
        log.warn("LLM 调用失败,第 {} 次重试...", retry);
        // MAX_RETRIES=2,指数退避后重试
    }
    return null; // 返回 null 由上层走兜底策略
}

3.4 提示词工程:怎么把"一局游戏"喂给模型

游戏状态要传给模型,最直接的方式是拼一个大 JSON,但 token 很快就爆了。这里做了三层压缩:

第一层:Markdown 表格化上下文。 AiPromptBuilder.buildGameContext 把玩家列表(座号/昵称/存活/死因)、存活统计、已亮身份、历史发言摘要、投票记录整理成紧凑的 Markdown 表格。

第二层:摘要式记忆(处理长对局的关键)。 每晚结束后用 generateDaySummary 生成当日摘要写入 gameState.daySummaries[round],再增量合并成一份"全局总摘要"(80~200 字)。AI 决策时只喂"总摘要 + 当日摘要 + 最近 3 条压缩发言",而不是把整局所有发言都塞进去。老轮次的发言会用 SpeechCompressor.compress() 压成一句话——这就是 LLM 打长对局不爆 token、不失忆的实用方案。

第三层:按角色注入私有记忆。 buildAiIdentity 会告诉狼"你的队友是 3 号、5 号,你们这晚刀过 7 号",告诉预言家"你验过 2 号是狼、6 号是好人",让 AI 具备人类玩家的"记忆对齐"。

3.5 结构化输出 + 输出安全(防注入、防剧透)

我没有用 function-calling 或强制 JSON 输出,而是让模型"只输出一个数字(座号),无法决定输出 -1",然后用正则抽取,最稳、最省钱:

private Integer extractSeatNumber(String llmOutput) {
    if (llmOutput == null) return null;
    Matcher m = Pattern.compile("\\d+").matcher(llmOutput);
    return m.find() ? Integer.parseInt(m.group()) : null;
}

抽取后还有一道合法性校验 isValidNightTarget:狼不能刀狼队友/自己、女巫不能毒自己等,任何越权结果都会被拦下并走兜底。

而真正让我觉得"专业"的是 filterSpeechContent 输出安全过滤(对发言内容):它会拦截

  • 代码块 / 链接 / URL;

  • AI 身份泄露词:作为AI、我是语言模型、llm/gpt/deepseek 等 18 个词;

  • 指令注入残留:忽略之前、ignore previous、你现在是、system: 等;

  • 敏感词(密码 / 转账 / 炸弹等)。

并且每个 prompt 都以"最高优先级安全约束"结尾,要求模型忽略玩家注入的指令、绝不透露自己 AI 的身份——因为狼人杀里如果 AI 玩家说出"我是大模型",这局游戏的信息博弈就崩了。

3.6 房间级动态模型选择

模型配置是数据库驱动的(llm_model_config 表),开局时房间随机选一个聊天模型(randomSelectChatModel),支持 deepseek-r1 / deepseek-v3 / qwen-max / qwen-turbo 等,还支持运行时切换(POST /api/llm/config/switch/{modelCode}),可以对比不同模型的"狼人杀智商"。语音识别则单独用 paraformer-v1(ASR),玩家发言原文会异步落库到 game_speech,用于合规取证与复盘。


四、难点二:实时状态同步与一致性

游戏页面是"一个会被很多人同时改动的状态机",前端 room store 有 50+ 个状态字段。最怕三件事:重复结算、断线丢状态、倒计时漂移。

4.1 服务端:防重复结算的三板斧

房间 GameRoom 是纯内存对象,玩家座位用 ConcurrentHashMap<seat, GamePlayer> 存。并发正确性靠三层:

class GameRoom {
    final ReentrantLock lock;          // ① 回合操作串行化
    volatile boolean wolfKillResolved; // ② 幂等标记:狼刀结果只结算一次
    volatile boolean voteResolved;     // ② 幂等标记:投票结果只结算一次
    ScheduledFuture<?> timeoutFuture;  // ③ 单飞超时任务,推进时取消防重复
}

举个实际例子——狼人投票结束的推进逻辑:

if (votedCount >= aliveCount && !room.voteResolved) {
    room.voteResolved = true;      // 先置幂等标记再结算
    executeVoteResult(room);
}

配合 @Sharable 的 Netty handler 与多线程的 AI 调度器,这类"谁都能触发推进"的地方全靠幂等标记兜底,注释里也明确写了防并发竞态的设计动机。

4.2 断线重连:状态全量 + 事件增量 + 消息补偿

对"中途刷新/断网"的用户,做了三层保护:

  1. 全量状态同步:重连后前端主动发 request_state_sync,服务端把当前房间完整状态下发;

  2. 事件增量同步:旁路的 EventStore(Redis 有序事件流)为每局维护 offset,前端通过 GET /room/{roomCode}/events/incremental 按自己的可见性过滤(玩家只能拿到自己该知道的事件,走的是服务端过滤而非前端过滤,避免"抓包作弊")拿到增量事件;lastEventOffset 就是断点续传的游标;

  3. 离线消息补偿:sendToUser 在用户不在线时把消息写入 pendingMessages(每人上限 50 条),重连握手成功后 flush 补发。

4.3 倒计时漂移:时钟同步

狼人杀全靠倒计时推进,而"浏览器 setTimeout 不准 + 服务端判定有延迟"会导致客户端倒计时和服务端真实截止时间不一致(最典型:本地显示还有 3 秒,服务端其实已经超时结算了)。解法是连接建立时做一次时钟同步:

前端发 time_sync_request(clientTime=T1)
后端回 time_sync_response(serverTime=T2, clientTime=T1)
前端算出 clockOffset = serverTime - (本地接收时刻 + T1)/2
所有倒计时 deadline 用 getAdjustedTime() 校正后渲染

前端 useGameWebSocket 里还专门为"预言家查验结果最少展示 3 秒,防止 night_start 过早把结果清掉"写了注释和定时器——这类为体验兜的细节,往往是最值钱的代码。

4.4 前端:指数退避重连

前端 useWebSocket.js 对断线做了非常完整的处理,直接看延迟策略就懂它的严谨:

// 第 1 次固定 3s,之后指数增长,上限 30s
function getReconnectDelay(attempt) {
  if (attempt <= 1) return reconnectBaseDelay;          // 3000ms
  const delay = reconnectBaseDelay * Math.pow(2, attempt - 1);
  return Math.min(delay, reconnectMaxDelay);            // 30000ms 封顶
}

还有几个细节值得抄作业:

  • 最大重连 20 次,第 3 次提示"网络已断开",第 10 次提示"网络不稳定",耗尽则跳回大厅;

  • 被踢下线的识别:服务端强制断开时 close reason 带 kicked,此时不重连、清 token、跳登录,避免"被踢了还在疯狂重连";

  • 游戏进行中(非 WAITING/ENDED)重连成功后主动发 request_state_sync,保证刷新后状态一致。


五、难点三:内存 + Redis 双层游戏状态

房间状态分两层:运行时全在内存(快),Redis 负责跨实例共享 / 崩溃恢复,MySQL 只存"元数据"。这是单机也能跑、未来想横向扩容也能平滑演进的设计。

5.1 三层各有分工

层

存什么

一致性策略

内存 ConcurrentHashMap

完整 GameRoom(含锁、幂等标记、调度句柄)

主写入点,操作前 lock

Redis

房间 JSON 快照、玩家↔房间映射、在线 ZSet、限流计数

写穿 + 30s 备份 + 停机前全量

MySQL

game_room 元数据、战绩、发言存证

关键节点(建房/开局/结算/解散)才落库

5.2 Redis Key 设计

Key

结构

用途

wolf:game:room:{roomCode}

String(JSON)

完整房间快照

wolf:game:player:room

Hash userId → roomCode

玩家当前所在房间

wolf:online:users

ZSet(score=心跳时间戳)

全平台在线状态

wolf:token:blacklist:*

String

多端踢人黑名单

wolf:rate:limit:*

String

接口限流计数

wolf:game:streak/*

String

成就连胜/胜场计数器

注意:房间存的是 String(JSON) 而非 Hash——因为 GameRoom 结构复杂且要保留 @class 类型信息(RedisConfig 里用 activateDefaultTyping(NON_FINAL)),JSON 整体读写最简单可靠。

5.3 Lua 脚本:把"并发正确性"下沉到 Redis

跨实例的原子操作全部用 Lua 脚本一次搞定,最典型的是玩家进房:必须"校验此人不在别的房间 + 写入房间 + 写入玩家映射"三件事原子完成,否则会出现同一玩家同时进两个房间的脏数据:

-- PLAYER_JOIN_ROOM_SCRIPT(语义示意)
-- 1) 若 field 已存在则返回冲突(防一玩家两房)
-- 2) HSET wolf:game:player:room  userId roomCode
-- 3) 加入房间的玩家集合

另一个有意思的是在线用户过期清理:以前是"先按分数 range 取出过期用户、再 zrem 删除",两步之间用户可能刚好刷新心跳被误删;改成一条 Lua:ZRANGEBYSCORE + ZREMRANGEBYSCORE 一步原子取出并删除,从根上消除竞态。

5.4 缓存生命周期的"三管齐下"

防雪崩/穿透/击穿之外,这个项目最值得学的是缓存生命周期管理:

  • CacheRestoreRunner(启动恢复):应用启动时先从 Redis 恢复完整房间;对 Redis 里没有、但 DB 里仍是 WAITING/PLAYING 的房间,按 DB 元数据重建并回写 Redis;

  • GameRoomCacheBackupTask(运行中兜底):每 30s 全量 syncAllToRedis();

  • CacheShutdownHook(停机兜底):监听 ContextClosedEvent,优雅停机前再全量落一次 Redis;

  • 加上 CacheWarmupService 延迟预热、GameRoomCleanupTask 定时清理 ENDED/DISMISSED 房间、扫"僵尸房"(房主离线 5 分钟自动转让、全员离线 15 分钟解散)……

一套组合拳下来,"进程随便崩、重启不丢房"基本做到了。另外还实现了纯单机版游戏的所有复杂规则都跑在确定性引擎里,AI 只是锦上添花——这也是为什么这个系统稳定。


六、游戏状态机与规则引擎

6.1 一局游戏的生命周期

WAITING ──startGame──▶ 第一夜 NIGHT
NIGHT ──endNightPhase──▶ DAY(死亡公告 → 发言 → 投票 → 猎人或平安)
DAY ──proceedToNextNight──▶ NIGHT(round+1)
任意阶段 ──checkGameEnd(屠边)──▶ ENDED ──5min后──▶ 清理房间

6.2 角色与夜晚行动顺序

游戏支持 6 种角色:狼人 / 村民 / 预言家 / 女巫 / 守卫 / 猎人(狼阵营走 isWolf=true),模式从 6 人到 12 人可配。夜晚采用固定链式推进 advanceNightStep:

GUARD(守卫) → WOLF(狼人投票刀人) → SEER(预言家验人)
→ WITCH(女巫用药) → HUNTER(猎人夜检) → 天亮结算

每一步会先 hasAliveRole 判断该角色是否还有人存活,没人就自动跳过。其中值得讲的规则细节:

  • 守卫:不能连续两晚守同一人(prevGuardedPlayer),第一晚额外 +5s 思考时间;

  • 狼人:多狼要内部投票选出刀谁(wolfKillVotes + wolf_vote_update 广播),投完统一确认,模拟"狼队商量";

  • 女巫:一晚只能用一瓶药,会收到"今晚谁被刀"的提示,且守卫行动在女巫之前结算,所以女巫能看到守卫奶没奶上;

  • 猎人:夜里只做"是否中毒"检查——被毒死的猎人不能开枪。

6.3 夜晚结算的"奶穿"规则

天亮结算 endNightPhase 是规则密集区,刀杀判定优先级如下(狼刀 vs 守卫/解药):

当晚情况

结果

被刀 + 被守 + 被救

仍死(奶穿规则)

被刀 + 仅被守

存活

被刀 + 仅被救

存活

被刀 + 无保护

死亡

毒药则不可解(守护/解药都不能抵消被毒者)。死亡公告后有个顺序细节很关键:代码注释强调必须先 checkGameEnd 再推 day_start,保证前端在进入白天前先收到胜负判定,避免"游戏已结束前端还停在白天"的状态错乱。

6.4 胜负与"屠边"

checkGameEnd 用经典屠边规则:

  • 好人阵营赢:存活狼人 = 0;

  • 狼人阵营赢:屠神(存活神职 = 0,神=非狼非民)或屠民(存活村民 = 0)。

结算时把全部玩家身份揭晓推给所有人(game_end),异步写入 game_record / game_record_player,再触发成就判定,5 分钟后清理房间给玩家留足看结果的时间。


七、把项目"做正规":一整套工程化基建

除了游戏玩法,这个项目让我觉得最"值钱"的是把下面这些做大项目才需要的基建都补全了。

7.1 无状态 JWT:自动续期 + 多端踢人

SecurityConfig 用白名单 + denyAll() 兜底(显式放行 /api/auth/**、/ws/** 等),JWT 有效期 2 小时,剩余不足 30 分钟自动续期——通过响应头 X-New-Token 带回新 token,前端静默更新,用户无感:

请求剩余 <30min  → refreshToken
                  → 响应头 X-New-Token: <新token>
                  → 响应头 X-Token-Remaining-Time: <剩余秒数>

多端互踢的实现很有意思:新登录后,旧 token 被写进 Redis 黑名单 wolf:token:blacklist:*,旧 token 的下一次请求在过滤器层直接 401;如果旧端还挂着 WebSocket,服务端会主动推 {"type":"kicked"} 并断开——前端识别到这个原因就不重连,直接跳登录,体验干净利落。

7.2 注解 + AOP 的横切能力

  • @OperateLog:通过 OperateLogAspect 环绕记录到 sys_operate_log(含耗时、入参/出参 JSON、成功失败),审计不留死角;

  • @RateLimiter:RateLimiterAspect 用固定窗口 SET key 1 EX window NX(首请求)+ INCR(递增)实现 IP/用户/路径维度的限流,还处理了"key 在 setIfAbsent 与 increment 之间过期重建"的极端情况。

7.3 统一错误码 + 三级国际化

错误码按段规划:10xxx 认证 / 20xxx 游戏 / 21xxx IM / 22xxx 群聊……且每条错误码都绑定一个 messageKey,如 error.user.not_found。GlobalExceptionHandler 按 messageKey → 原生 message → 中文硬编码 三级降级取文案,后端抛出的所有异常在前端都能直接显示成用户语言——后端负责翻译、前端零硬编码,中英切换对错误提示也生效。

后端消息结构则统一为:

public class Result<T> { int code; String message; T data; }

所有 WS 系统消息也带 i18nKey,前端统一走 translateMsg():有 i18nKey 用翻译,否则回落 message——这个约定让"系统消息"天然双语。

7.4 MDC 链路追踪 + 结构化日志

  • MdcInterceptor 为每个请求生成 16 位 requestId,并把当前 userId / username 塞进 SLF4J MDC,请求结束清理;

  • logback-spring.xml 控制台输出带 [%X{requestId:-}];文件用 Logstash JSON Encoder 输出结构化日志,customFields 携带 app/environment,并把 requestId/userId/username/roomCode 导出——丢给日志平台可直接按链路/用户/房间检索;

  • 游戏 / AI / WS 走独立 game.log,dev 开 DEBUG、非 dev 只 INFO,避免生产被 AI 决策日志刷爆。

7.5 成就系统:实时 Redis + 每日 DB 补偿"双保险"

成就走数据驱动(achievement 表定义 key/类型/条件/奖杯/积分),覆盖首胜、连胜(3~20 连胜)、角色胜(狼王/预言家/女巫/猎人/守卫/平民英雄)、局数(10~500 局)、以及"完美胜利"(全程存活且胜利)等。

它的设计亮点是计数可靠性:实时用 Redis 计数器累加(响应快),但 Redis 有丢数据风险,于是 AchievementCompensationTask 每天凌晨 3 点从 game_record_player 拉取战绩重算补偿,初始化缺失记录——防 Redis 计数器过期导致进度丢失,双保险。

7.6 即时通讯子系统

好友 / 私聊 / 群聊一应俱全:好友搜索与申请、备注、群聊含群已读回执(im_group_message_read)、禁言/转让/管理员/群昵称。群消息发送后会区分"已读/未读",是相对完整的一个 IM 子系统。


八、前端:赛博朋克设计系统与体验细节

前端最抢眼的是设计系统——它不是随便用用 Element Plus,而是定义了一整套"赛博朋克" Design Tokens:

// variables.scss 摘录
// 主色:青蓝  |  辅色:品红  |  强调:翠绿
$--color-primary:   #00e5ff;   // 青蓝(主按钮/链接/发光)
$--color-secondary: #ff00e5;   // 品红(强调)
$--color-accent:    #00ffa3;   // 翠绿(成功/加分)

// 角色专属色
$--color-wolf:      #ff3344;   // 狼人·红
$--color-seer:      #aa66ff;   // 预言家·紫
$--color-witch:     #66ffaa;   // 女巫·绿
$--color-hunter:    #ff8833;   // 猎人·橙
$--color-guard:     #3388ff;   // 守卫·蓝

// 深空背景
$--bg-color: #0a0a12;
$--bg-color-card: #14142a;

配合首页的粒子漂浮背景、房间的网格光晕、按钮的霓虹发光,整体是"暗夜 × 霓虹"的狼人杀氛围。角色色贯穿全程:头像高亮、发言标识、死亡变灰、出局原因都用语义色区分,玩家扫一眼就能读懂局面。

多端适配方面,src/pc/、src/mobile/ 目前是"独立视图的规划目录",当前实际策略是共享一套 src/views + CSS 媒体查询做响应式:桌面是三栏布局,移动端则用抽屉收起来,顶部只留分享/离开/解散按钮。这样一套代码同时覆盖 PC 和手机,且为将来"拆独立视图"留好了扩展位。

其他值得一提的前端细节:

  • VirtualList 虚拟列表 + MessageList:房间聊天/群聊消息多了也不卡(配合 useMessageStore 做增量存储、maxMessages 截断);

  • 语音:VoiceChannel / VoiceRecorder / VoiceMessage 实现了房间内"语音房"式体验(静音/闭麦信令只广播给房间其他人,不经服务端转发音频,压低延迟);

  • 阶段动画:PhaseTransition(天黑天亮转场)、PhaseSummaryCard(回合小结卡片先播完再弹结果,避免跳变)、RoleRevealModal(身份揭晓)、GameTimer(颜色随剩余秒数渐变、读秒紧迫感);

  • 音效 useGameSound、新手引导 OnboardingGuide、主题/语言切换(ThemeToggle / LanguageSelector)一应俱全;

  • 构建优化:Vite 手动分包(vue/element/utils 独立 chunk)、Element Plus 全自动按需引入。


九、部署与多环境

后端三套环境(dev/test/prod)通过 spring.profiles.active 切换,生产域名走 HTTPS。前端通过 VITE_API_BASE_URL / VITE_WS_URL 区分:

# 前端 .env.development
VITE_API_BASE_URL=/api
VITE_WS_URL=ws://localhost:8080
# 生产环境留空 → 走 Nginx 同源反向代理,wss 自动跟随 https

后端部署脚本 start.sh 也写得相当工程化:

  • JVM 参数:-Xms512m -Xmx1024m -XX:+UseG1GC(G1 + 目标停顿 200ms);

  • 显式开启 GC 日志文件 + OOM 自动堆转储(-XX:+HeapDumpOnOutOfMemoryError)——线上出问题不用等现场;

  • 支持 start / stop / restart / status,PID 文件管理,优雅停机 30s 内 kill、超时再 kill -9。

另外为了配置安全还引入了 Jasypt(ENC(...) 加密串),虽然当前配置文件仍是明文、尚未完全启用,但工具链(JasyptUtil,PBEWITHHMACSHA512ANDAES_256)已经就位,属于"能力先行、随时可落地"。


十、踩过的坑与设计取舍(复盘)

写博客我最喜欢诚实地记录"当时纠结过什么":

  1. 浏览器 WebSocket 无法自定义 Header → 认证只能走 URL ?token=。后端其实写好了一套 HMAC-SHA256 signature/timestamp/nonce 反重放校验,但因为浏览器限制最终注释掉了。如果未来做原生客户端(能自由设 Header/签名),可以重新启用。这就是"协议设计要为客户端能力妥协"。

  2. LLM 不能无限等 → 坚决不用流式响应做游戏决策,15s 超时 + 重试 + 兜底三步走,宁可 AI 说一句模板话,也不让整局卡 30 秒。

  3. Redis 反序列化会丢东西 → GameRoom 反序列化可能丢失锁/类型信息,所以 RedisConfig 必须开 activateDefaultTyping 保留 @class,业务代码里多处"从 DB 重新 select 恢复 gameConfig"的兜底正是为了治这个。

  4. 发言历史不能无限喂给模型 → 摘要 + 压缩 + 只保留最近 3 条,是 token 成本与"AI 记忆"的平衡点。

  5. 大厅广播不能太频繁 → 在线人数广播做了 3 秒节流 + CAS,防"每人每秒上下线波动"把广播打成 O(N²)。

  6. 被踢和断线要区分 → 前端通过 close reason 区分"被踢下线"和"网络抖动",前者不重连直接跳登录,体验完全不同。


十一、项目结构与跑起来

完整代码结构(后端 + 前端)可供查阅:后端 game-wolf(Spring Boot),前端 game-wolf-ui(Vue 3)。本地快速体验只需准备 JDK 17 / MySQL 8 / Redis 7:

# 后端
CREATE DATABASE game_wolf DEFAULT CHARACTER SET utf8mb4;
# 依次执行 sql/schema.sql(表结构)、sql/data.sql(模式/AI模型/成就)
mvn spring-boot:run
​
# 前端
npm i
npm run dev   # 默认连 ws://localhost:8080

进大厅建房 → 人不够就 添加 AI 玩家 → 开局,就能亲眼看到 AI 玩家发言、投票、甚至在你掉线时接管你的身份了。


十二、总结

一句话总结这个项目带给我的启发:在线博弈游戏的技术难点不在"某个算法",而在"把状态机做对 + 把实时消息做稳 + 把 AI 做成不会卡局的参与者",最后再用工程化基建把这些能力兜住。

  • 实时:WebSocket / Netty 双通道按消息类型分工、断线降级、消息补偿;

  • 正确:内存 + Redis + MySQL 三层、Lua 原子脚本、幂等标记、事件增量同步;

  • 智能:Spring AI 让 AI 会玩不剧透,还顺带解决了真人掉线托管;

  • 正规:JWT 续期/多端踢人、AOP 限流与审计、统一错误码国际化、MDC 结构化日志、成就双保险。

如果你也在做"强实时多人对局"或"LLM 与规则引擎结合"的项目,希望这些取舍能给你一些参考。有问题欢迎评论交流~

项目体验地址:https://www.shturl.online

评论