From b8d081bd8a06cb6d9d9cfef9013430b9e0fb8ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Thu, 11 Sep 2025 15:13:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E7=9A=84=E9=A1=B9=E7=9B=AE=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E6=8D=A2=E6=97=A7=E7=9A=84=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=E9=9C=80=E6=B1=82=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 01_文档/准备和打击页面游戏原型设计需求.md | 155 ---- 01_文档/初步设计文档.md | 668 ++++++++++++++ 02_详细设计文档/API接口设计详设.md | 268 ++++++ 02_详细设计文档/UI组件设计详设.md | 251 +++++ 02_详细设计文档/前端技术架构详设.md | 1027 +++++++++++++++++++++ 02_详细设计文档/后端技术架构详设.md | 887 ++++++++++++++++++ 02_详细设计文档/实时通信模块详设.md | 280 ++++++ 02_详细设计文档/数据库设计详设.md | 210 +++++ 02_详细设计文档/游戏核心逻辑详设.md | 908 ++++++++++++++++++ 02_详细设计文档/部署运维详设.md | 256 +++++ 10 files changed, 4755 insertions(+), 155 deletions(-) delete mode 100644 01_文档/准备和打击页面游戏原型设计需求.md create mode 100644 01_文档/初步设计文档.md create mode 100644 02_详细设计文档/API接口设计详设.md create mode 100644 02_详细设计文档/UI组件设计详设.md create mode 100644 02_详细设计文档/前端技术架构详设.md create mode 100644 02_详细设计文档/后端技术架构详设.md create mode 100644 02_详细设计文档/实时通信模块详设.md create mode 100644 02_详细设计文档/数据库设计详设.md create mode 100644 02_详细设计文档/游戏核心逻辑详设.md create mode 100644 02_详细设计文档/部署运维详设.md diff --git a/01_文档/准备和打击页面游戏原型设计需求.md b/01_文档/准备和打击页面游戏原型设计需求.md deleted file mode 100644 index 2bd07c1..0000000 --- a/01_文档/准备和打击页面游戏原型设计需求.md +++ /dev/null @@ -1,155 +0,0 @@ -# “打飞机”小程序App原型设计需求文档 - -## 1. 概述 - -本文档旨在为“打飞机”小程序的核心界面提供清晰的设计需求,主要面向UI/UX设计人员,用于指导原型设计。文档充分考虑了手机触摸屏的操作特性。核心界面包括“准备页面”和“打击页面”。 - ---- - -## 2. 核心页面设计需求 - -### 2.1 准备页面 - -**页面目标:** 玩家在此页面中通过触控操作完成飞机的布局,并确认进入“准备就绪”状态。 - -#### 2.1.1 界面核心元素 - -1. **游戏棋盘 (10x10 网格):** - * **功能:** 用于放置飞机的主要区域。 - * **设计要求:** - * 应为一个 10x10 的网格布局,每个格子大小一致,适合触控点击。 - * 格子有清晰的边框。 - * **触控反馈:** 点击格子时,应有视觉反馈(如背景色瞬时变化或涟漪效果)。 - * 坐标标识:在棋盘的上方和左侧应有坐标轴,上方为字母(A-J),左侧为数字(1-10)。 - -2. **飞机状态与选择区:** - * **功能:** 展示可用的飞机,并允许玩家选择要操作的飞机。 - * **设计要求:** - * 提供三个按钮,分别代表“飞机1”、“飞机2”、“飞机3”。 - * 当一架飞机被成功放置到棋盘上后,对应的按钮应变为“选中”或“已放置”状态(如改变颜色或样式)。 - * 当玩家在棋盘上**点击**已放置的飞机时,此区域对应的飞机按钮也应同步显示为“当前选中”状态。 - * 如果所有飞机都已放置,此区域的按钮都应处于“已放置”状态。 - -3. **布局操作按钮组 (针对触控优化):** - * **功能:** 控制飞机的放置、移动和旋转。 - * **设计要求:** - * **方向控制:** “上、下、左、右”四个方向按钮,用于在添加飞机前,预设飞机的机头朝向。**此为第一种放置方式的辅助按钮。** - * **移动控制:** 提供四个方向箭头按钮(上、下、左、右),用于在棋盘上移动当前选中的飞机。 - * **旋转控制:** 提供一个旋转按钮,用于顺时针旋转当前选中的飞机。 - * **删除按钮:** 用于删除当前在棋盘上选中的飞机。 - -4. **准备确认按钮组:** - * **功能:** 玩家完成布局后,进行最终确认。 - * **设计要求:** - * **完成按钮:** - * 当棋盘上没有满3架飞机时,此按钮应为“禁用”状态。 - * 当3架飞机都已放置好后,此按钮变为“可用”状态。 - * **删除按钮:** (此处有重复,建议整合)一个“清空”或“重置”按钮,用于一键清除所有已放置的飞机,方便重新布局。 - -5. **状态提示区:** - * **功能:** 向玩家反馈当前的游戏状态。 - * **设计要求:** - * 在页面底部或一个显著位置,显示文本信息,如“请放置三架飞机”、“已准备,等待对手中...”、“对手已准备就绪”。 - -#### 2.1.2 交互流程 - -1. **进入页面:** 默认显示一个空的10x10棋盘。 -2. **放置飞机:** - - * **直接点击机身** - * 玩家直接点击棋盘上的一个**空白**格子。 - * 系统以此格为飞机的中心点(或机身的一部分),自动生成一架默认朝向(例如,机头朝上)的飞机。 - * 如果空间不足以放置飞机,应给出提示(如格子闪烁红色或toast提示),且不放置飞机。 - - * **通用规则:** - * 飞机成功放置后,“飞机x”按钮状态改变,表示一架飞机已被使用。 - * 当3架飞机都放置完毕后,放置功能自动禁用。 - -3. **选择与操作飞机:** - * **单击**棋盘上已放置的飞机,该飞机进入“选中”状态(如高亮或边框变化),此时可对它进行移动或旋转。 - * 使用移动和旋转按钮调整“选中”飞机的位置和形态。 - * 点击“删除”按钮,移除选中的飞机。 -4. **完成准备:** - * 当3架飞机全部放置后,“完成”按钮激活。 - * 玩家点击“完成”,布局操作按钮全部变为“禁用”状态,页面提示“已准备,等待对手中...”。 - ---- - -### 2.2 对战页面 - -**页面目标:** 玩家在此页面进行回合制对战,包括攻击对手和观察我方被攻击情况。页面分为“我的回合”和“对手回合”两种状态。 - ---- - -#### 2.2.1 我的回合 (攻击对手) - -**核心目标:** 攻击对手棋盘,并可使用模拟功能辅助决策。 - -**界面核心元素:** - -1. **攻击棋盘 (10x10 网格):** - * **功能:** 攻击对手的核心区域,同时内置飞机布局模拟功能。棋盘上会叠加显示历史攻击结果和当前模拟的飞机。 - * **设计要求:** - * 样式与准备页面一致,带坐标轴。 - * **攻击结果状态:** - * **打偏 (未命中):** 显示“X”或特定图标。 - * **命中:** 显示火焰等命中效果。 - * **摧毁:** 显示强烈的摧毁效果。 - * **模拟飞机样式:** 模拟放置的飞机应有独特的视觉样式(如半透明、虚线边框),与真实的攻击结果标记明确区分。 - -2. **飞机模拟区:** - * **功能:** 提供可用于模拟的飞机模型。 - * **设计要求:** - * 此区域的设计应参考**准备页面**的“飞机状态与选择区”和“布局操作按钮组”。 - * 玩家可从此区域选择并操作飞机,将其放置到上方的攻击棋盘上进行模拟。 - -3. **操作与状态区:** - * **功能:** 执行打击操作和显示信息。 - * **设计要求:** - * **打击按钮:** 玩家在棋盘上**点击**一个未攻击过的格子作为目标后,此按钮激活。 - * **状态提示:** 清晰显示“我的回合”。 - * **攻击结果反馈:** 每次攻击后,应有弹窗或醒目提示。 - -**交互流程:** - -1. **进入回合:** 自动切换至此页面,显示对手的棋盘(包含我方历史攻击记录)。 -2. **决策与操作 (可随时进行):** - * **模拟布局:** 从“飞机模拟区”选择飞机,在攻击棋盘上进行拖拽、旋转、删除等操作,以推演对手可能的布局。这些模拟飞机仅为临时辅助,不影响攻击。 - * **选择攻击目标:** **单击**棋盘上任一未攻击过的格子,该格子高亮,表示“待打击”,同时“打击”按钮激活。 -3. **确认打击:** 点击“打击”按钮。发起攻击后,棋盘上所有模拟的飞机将自动清除。 -4. **等待并展示结果:** - * 棋盘上对应的格子根据结果(命中/打偏/摧毁)更新其视觉状态。 - * 弹出醒目的结果提示。 -5. **回合结束:** 自动切换到“对手回合”页面。 - ---- - -#### 2.2.2 对手回合 (观察我方) - -**核心目标:** 清晰地看到我方棋盘的被攻击情况,并能实时观察到对手的瞄准意图。 - -**界面核心元素:** - -1. **我方棋盘 (10x10 网格):** - * **功能:** 显示我方飞机布局以及对手的所有攻击记录。 - * **设计要求:** - * 清晰展示我方三架飞机的完整布局。 - * **格子状态:** - * **被攻击 (打偏):** 对手攻击但我方该位置无飞机部分。 - * **被攻击 (命中):** 对手攻击命中我方飞机部件。 - * **对手正在瞄准:** 实时显示对手当前选择的、但还未确认攻击的格子(例如,用闪烁的准星或特殊的边框高亮),此状态会随对手的选择而实时变化。 - * **机头被摧毁:** 当机头被击中时,整架飞机需要有特殊且醒目的“被摧毁”样式(例如,整架飞机变灰并显示爆炸/损坏图标),以明确表示该飞机已失效。 - -2. **操作与状态区:** - * **功能:** 显示状态并提供视图切换功能。 - * **设计要求:** - * **状态提示:** 清晰显示“对手回合”。 - * **切换至攻击视图按钮:** 提供一个明确的按钮(如“切换到攻击页”或“战术模拟”),允许玩家在对手回合时,手动切换回“我的回合”的攻击与模拟棋盘,以便利用对手的思考时间进行我方的策略规划。 - -**交互流程:** - -1. **进入回合:** 自动切换至此页面,显示我方棋盘和被攻击情况。 -2. **观察对手:** 实时看到对手正在瞄准的格子位置变化。 -3. **结果展示:** 当对手确认攻击后,棋盘上对应格子更新状态(命中/打偏),如果是机头,则整架飞机更新为“被摧毁”状态。 -4. **可选操作:** 玩家可随时点击“切换至攻击视图”按钮,跳转回自己的攻击与模拟棋盘进行思考。 -5. **回合结束:** 对手攻击完成后,系统自动切换回“我的回合”页面。 diff --git a/01_文档/初步设计文档.md b/01_文档/初步设计文档.md new file mode 100644 index 0000000..249362b --- /dev/null +++ b/01_文档/初步设计文档.md @@ -0,0 +1,668 @@ +# 打飞机小程序技术选型与功能架构设计文档 + +> **撰写人**: 移动端产品架构专家 | 苹果设计奖获得者 +> **文档版本**: v1.0 +> **创建日期**: 2024年9月11日 +> **项目**: 打飞机对战小程序 + +## 1. 项目概述与原型分析 + +### 1.1 核心功能模块 +基于原型设计分析,项目包含以下核心功能: + +**页面结构**: +- 入口页面:用户登录、游戏模式选择 +- 房间管理:创建房间、加入房间、房间等待 +- 游戏核心:飞机布置、实时对战 +- 用户系统:个人信息、战绩统计 + +**设计特色**: +- 深色科技主题UI设计 +- 移动优先的交互体验 +- 高度优化的触控操作 +- 实时对战能力 + +### 1.2 MVP原则遵循 +严格按照原型设计实现,不增加额外功能: +- 基础双人对战游戏 +- 房间制匹配模式 +- 简洁用户界面 +- 核心游戏机制 + +## 2. 技术选型方案 + +### 2.1 前端技术栈 + +#### 2.1.1 小程序框架选择 +**推荐:Taro 4.x + React 18** + +```typescript +// 技术栈配置 +{ + "framework": "Taro 4.x", + "ui": "React 18 + TypeScript", + "css": "Sass + CSS Modules", + "state": "Zustand", + "http": "Taro.request + axios适配", + "websocket": "Taro WebSocket API" +} +``` + +**选择理由**: +- 一码多端,可快速扩展到H5/APP +- React生态成熟,组件复用度高 +- TypeScript保证代码质量 +- 与原型设计的现代化风格匹配 + +#### 2.1.2 状态管理 +**Zustand** - 轻量级状态管理 + +```typescript +// 游戏状态Store结构 +interface GameStore { + // 用户状态 + user: UserInfo | null + // 房间状态 + room: RoomInfo | null + // 游戏状态 + gameState: GameState | null + // 连接状态 + connectionState: 'connected' | 'connecting' | 'disconnected' +} +``` + +#### 2.1.3 UI组件设计 +基于原型的深色科技主题: + +```scss +// 设计Token +$primary-color: #6366f1; +$secondary-color: #40e0d0; +$bg-primary: #0f1419; +$gradient-bg: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 30%, #16213e 70%, #0f3460 100%); +``` + +### 2.2 后端技术架构 + +#### 2.2.1 服务端选择 +**Node.js + TypeScript + Fastify** + +```typescript +// 服务端架构 +{ + "runtime": "Node.js 18+", + "framework": "Fastify", + "language": "TypeScript", + "websocket": "ws + socket.io", + "database": "MongoDB + Redis", + "deployment": "Docker + PM2" +} +``` + +**架构优势**: +- 高性能异步处理 +- WebSocket原生支持 +- TypeScript类型安全 +- 快速开发迭代 + +#### 2.2.2 数据存储设计 + +```typescript +// MongoDB数据模型 +interface User { + _id: ObjectId + openid: string + nickname: string + avatar: string + stats: { + totalGames: number + wins: number + winRate: number + } + createdAt: Date +} + +interface Room { + _id: ObjectId + roomCode: string + hostId: string + guestId?: string + status: 'waiting' | 'playing' | 'finished' + gameData?: GameData + createdAt: Date +} + +interface GameSession { + _id: ObjectId + roomId: string + players: [string, string] + gameState: GameStateData + moves: Move[] + result?: GameResult +} +``` + +```redis +// Redis缓存策略 +ROOM:{roomCode} -> RoomData (TTL: 1小时) +USER_ONLINE:{userId} -> ConnectionInfo (TTL: 30分钟) +GAME_SESSION:{sessionId} -> GameState (TTL: 2小时) +``` + +### 2.3 实时通信方案 + +#### 2.3.1 WebSocket架构 +基于原型的实时对战需求: + +```typescript +// WebSocket消息协议 +interface GameMessage { + type: 'JOIN_ROOM' | 'PLACE_PLANES' | 'ATTACK' | 'GAME_STATE_SYNC' + roomCode: string + userId: string + data: any + timestamp: number +} + +// 实时功能支持 +- 房间状态同步 +- 对手攻击实时显示(原型中的实时瞄准提示) +- 游戏状态广播 +- 断线重连机制 +``` + +## 3. 核心功能架构设计 + +### 3.1 游戏核心逻辑 + +#### 3.1.1 飞机模型设计 +基于原型设计需求: + +```typescript +// 飞机几何模型 +interface Plane { + id: string + center: Position + direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT' + positions: Position[] // 11个位置 + isDestroyed: boolean + parts: { + head: Position + wings: Position[] // 5个位置 + body: Position[] // 2个位置 + tail: Position[] // 3个位置 + } +} + +// 10x10棋盘表示 +interface GameBoard { + cells: Cell[][] // 10x10网格 + planes: Plane[] // 3架飞机 +} + +interface Cell { + position: Position + state: 'EMPTY' | 'PLANE_PART' | 'ATTACKED_MISS' | 'ATTACKED_HIT' + planeId?: string +} +``` + +#### 3.1.2 攻击逻辑 +```typescript +// 攻击结果类型 +interface AttackResult { + type: 'MISS' | 'HIT' | 'DESTROY' + position: Position + planeId?: string + gameEnded?: boolean + winner?: string +} + +// 攻击处理逻辑 +class GameEngine { + processAttack( + board: GameBoard, + position: Position + ): AttackResult { + // 1. 检查位置有效性 + // 2. 判断攻击结果(miss/hit/destroy) + // 3. 更新游戏状态 + // 4. 检查游戏结束条件 + } +} +``` + +### 3.2 页面功能模块 + +#### 3.2.1 入口页面模块 +基于 [`01_入口页面.html`](01_文档/原型设计/01_入口页面.html) 设计: + +```typescript +// 入口页面功能 +interface EntryPageFeatures { + // 用户信息显示 + userProfile: { + avatar: string + nickname: string + level?: number + } + + // 游戏模式选择 + gameMode: { + quickStart: () => void // AI对战 + onlineMatch: () => void // 自动匹配 + createRoom: () => void // 创建房间 + joinGame: () => void // 加入游戏 + } + + // 游戏规则显示 + rulesDisplay: boolean +} +``` + +#### 3.2.2 房间管理模块 +基于原型设计的房间功能: + +```typescript +// 房间管理功能 +interface RoomManager { + // 创建房间 + createRoom(): Promise<{roomCode: string}> + + // 加入房间 + joinRoom(roomCode: string): Promise + + // 房间状态同步 + syncRoomState(): void + + // 玩家准备状态 + setPlayerReady(ready: boolean): void +} +``` + +#### 3.2.3 游戏对战模块 +基于原型的对战页面设计: + +```typescript +// 对战功能模块 +interface BattleModule { + // 飞机布置阶段 + placementPhase: { + placePlane(center: Position, direction: Direction): boolean + autoPlace(): void + confirmPlacement(): void + } + + // 攻击阶段 + attackPhase: { + selectTarget(position: Position): void + confirmAttack(): void + showAttackResult(result: AttackResult): void + } + + // 实时功能(原型中的实时瞄准显示) + realTimeFeatures: { + showOpponentAiming(position: Position): void + hideOpponentAiming(): void + syncGameState(): void + } +} +``` + +### 3.3 数据流架构 + +#### 3.3.1 状态管理流程 +``` +用户操作 -> Action派发 -> Store更新 -> 组件重渲染 + | + WebSocket同步 + | + 服务端处理 -> 状态广播 -> 对手端更新 +``` + +#### 3.3.2 游戏流程设计 +基于原型页面流程: + +```typescript +// 游戏状态机 +enum GamePhase { + WAITING = 'waiting', // 等待对手 + PLACING = 'placing', // 布置飞机 + BATTLING = 'battling', // 对战中 + FINISHED = 'finished' // 游戏结束 +} + +// 回合控制 +interface TurnManager { + currentPlayer: string + phase: GamePhase + timeLimit: number + switchTurn(): void + checkGameEnd(): boolean +} +``` + +## 4. 技术实现细节 + +### 4.1 小程序适配策略 + +#### 4.1.1 页面路由设计 +```typescript +// 页面路由配置 +const pages = [ + 'pages/entry/index', // 入口页面 + 'pages/room/create', // 创建房间 + 'pages/room/join', // 加入房间 + 'pages/room/waiting', // 等待房间 + 'pages/game/prepare', // 准备页面 + 'pages/game/battle' // 对战页面 +] + +// 路由管理 +class Navigator { + static toRoomCreate() { + wx.navigateTo({ url: '/pages/room/create' }) + } + + static toGameBattle(roomCode: string) { + wx.navigateTo({ + url: `/pages/game/battle?roomCode=${roomCode}` + }) + } +} +``` + +#### 4.1.2 原生API集成 +```typescript +// 微信小程序API封装 +class WxAPI { + // 用户授权 + static async getUserInfo(): Promise { + const {userInfo} = await wx.getUserProfile({ + desc: '用于显示用户信息' + }) + return userInfo + } + + // 震动反馈(基于原型的触觉反馈) + static vibrate(): void { + wx.vibrateShort({ type: 'light' }) + } + + // 分享功能 + static onShareAppMessage() { + return { + title: '打飞机对战', + path: '/pages/entry/index' + } + } +} +``` + +### 4.2 性能优化方案 + +#### 4.2.1 渲染优化 +基于原型的复杂UI需求: + +```typescript +// 游戏棋盘优化渲染 +class BoardRenderer { + private shouldUpdate = false + private renderQueue: Position[] = [] + + // 批量更新棋盘状态 + batchUpdateCells(updates: CellUpdate[]): void { + updates.forEach(update => { + this.renderQueue.push(update.position) + }) + + if (!this.shouldUpdate) { + this.shouldUpdate = true + this.nextTick(() => this.flushUpdates()) + } + } + + private flushUpdates(): void { + // 批量DOM更新 + this.renderQueue.forEach(pos => this.updateCell(pos)) + this.renderQueue = [] + this.shouldUpdate = false + } +} +``` + +#### 4.2.2 网络优化 +```typescript +// 请求优化和缓存 +class NetworkManager { + private cache = new Map() + private pending = new Map>() + + async request( + url: string, + options?: RequestOptions + ): Promise { + const cacheKey = `${url}:${JSON.stringify(options)}` + + // 检查缓存 + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) + } + + // 检查pending请求 + if (this.pending.has(cacheKey)) { + return this.pending.get(cacheKey) + } + + // 发起新请求 + const promise = this.makeRequest(url, options) + this.pending.set(cacheKey, promise) + + try { + const result = await promise + this.cache.set(cacheKey, result) + return result + } finally { + this.pending.delete(cacheKey) + } + } +} +``` + +### 4.3 错误处理和监控 + +#### 4.3.1 错误边界 +```typescript +// React错误边界组件 +class GameErrorBoundary extends React.Component { + state = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // 错误上报 + this.reportError(error, errorInfo) + } + + private reportError(error: Error, errorInfo: ErrorInfo) { + // 上报到监控系统 + wx.reportMonitor('game_error', { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack + }) + } + + render() { + if (this.state.hasError) { + return this.setState({ hasError: false })} /> + } + + return this.props.children + } +} +``` + +## 5. 部署和运维方案 + +### 5.1 开发环境配置 +```json +{ + "scripts": { + "dev": "taro build --type weapp --watch", + "build": "taro build --type weapp", + "build:h5": "taro build --type h5", + "deploy": "npm run build && npm run upload" + }, + "dependencies": { + "@tarojs/taro": "^4.0.0", + "@tarojs/plugin-react": "^4.0.0", + "react": "^18.2.0", + "zustand": "^4.4.0" + } +} +``` + +### 5.2 服务端部署 +```dockerfile +# 服务端Docker配置 +FROM node:18-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY . . +EXPOSE 3000 +CMD ["node", "dist/server.js"] +``` + +```yaml +# docker-compose.yml +version: '3.8' +services: + game-server: + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - MONGODB_URI=${MONGODB_URI} + - REDIS_URI=${REDIS_URI} + depends_on: + - mongodb + - redis + + mongodb: + image: mongo:6.0 + volumes: + - mongo_data:/data/db + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data +``` + +### 5.3 监控和日志 +```typescript +// 游戏性能监控 +class PerformanceMonitor { + // 游戏关键指标监控 + trackGameMetrics() { + // 游戏启动时间 + const startTime = Date.now() + + // 页面加载性能 + wx.reportPerformance(1001, { + category: 'page_load', + name: 'entry_page', + value: Date.now() - startTime + }) + + // 游戏操作响应时间 + this.trackUserInteraction() + } + + // WebSocket连接质量监控 + trackConnectionQuality() { + let pingStart = Date.now() + + this.websocket.ping() + this.websocket.onPong(() => { + const latency = Date.now() - pingStart + wx.reportPerformance(1002, { + category: 'network', + name: 'websocket_latency', + value: latency + }) + }) + } +} +``` + +## 6. 开发计划和里程碑 + +### 6.1 开发阶段规划 +``` +第一阶段 (1周): +- 项目搭建 (2天) +- 基础UI开发 (5天) + +第二阶段 (2周): +- 用户系统 (3天) +- 房间功能 (4天) +- 游戏核心逻辑 (6天) +- 实时通信 (3天) + +第三阶段 (1周): +- 功能测试 (3天) +- 性能优化 (2天) +- 发布准备 (1天) +``` + +### 6.2 关键里程碑 +- **Week 1**: 完成基础框架搭建和UI实现 +- **Week 2**: 实现房间管理和游戏核心逻辑 +- **Week 3**: 完成实时对战功能和测试优化 +- **Week 4**: 发布上线和后续迭代 + +## 7. 风险评估和应对策略 + +### 7.1 技术风险 +| 风险项 | 影响程度 | 应对策略 | +|-------|---------|----------| +| WebSocket连接稳定性 | 高 | 实现自动重连+离线缓存 | +| 小程序包体积限制 | 中 | 代码分包+资源CDN | +| 游戏状态同步复杂度 | 高 | 状态机设计+冲突解决 | + +### 7.2 用户体验风险 +| 风险项 | 影响程度 | 应对策略 | +|-------|---------|----------| +| 网络延迟影响体验 | 中 | 乐观更新+加载动画 | +| 不同设备适配问题 | 中 | 响应式设计+设备测试 | +| 游戏规则理解困难 | 低 | 新手引导+操作提示 | + +## 8. 总结 + +本设计文档基于提供的原型设计,严格遵循MVP原则,提出了一套完整的微信小程序技术解决方案: + +**核心优势**: +- 与原型设计高度一致的技术选型 +- 现代化的前端架构和深色科技主题UI +- 高性能的实时对战体验 +- 完善的错误处理和监控机制 +- 清晰的开发计划和风险控制 + +**实现要点**: +- 使用Taro + React实现跨端能力 +- Node.js + WebSocket保证实时性能 +- MongoDB + Redis提供数据支撑 +- 完整的状态管理和错误处理 + +本方案将为您的打飞机小程序提供坚实的技术基础,确保项目按时高质量交付。 + +--- +**文档状态**: ✅ 完成 +**技术评审**: 待进行 +**下一步**: 开始技术选型实施 \ No newline at end of file diff --git a/02_详细设计文档/API接口设计详设.md b/02_详细设计文档/API接口设计详设.md new file mode 100644 index 0000000..e8a4ec6 --- /dev/null +++ b/02_详细设计文档/API接口设计详设.md @@ -0,0 +1,268 @@ +# API接口设计详设文档 + +> **文档版本**: v1.0 +> **撰写人**: 后端架构师 +> **创建日期**: 2024年9月11日 + +## 1. 设计原则与规范 + +- **RESTful风格**: API遵循RESTful设计原则,使用标准的HTTP方法 (`GET`, `POST`, `PUT`, `DELETE`)。 +- **JSON格式**: 所有请求体和响应体均使用`application/json`格式。 +- **URL版本控制**: API版本通过URL前缀进行管理,例如 `/api/v1/...`。 +- **身份认证**: 所有需要认证的接口都通过`Authorization: Bearer `头进行身份验证。 +- **统一响应格式**: 所有API响应都遵循统一的数据结构,便于客户端处理。 +- **错误处理**: 使用标准的HTTP状态码表示请求结果,并在响应体中提供详细的错误信息。 + +## 2. 统一响应格式 + +### 2.1 成功响应 (`2xx`) + +```json +{ + "success": true, + "code": 200, + "message": "操作成功", + "data": { + "key1": "value1", + "key2": "value2" + } +} +``` + +### 2.2 失败响应 (`4xx`, `5xx`) + +```json +{ + "success": false, + "code": 401, + "message": "身份认证失败", + "error": { + "type": "AuthenticationError", + "details": "JWT token is invalid or expired." + } +} +``` + +## 3. API接口详解 + +### 3.1 用户认证模块 (`/api/v1/auth`) + +#### 3.1.1 `POST /auth/login` +- **功能**: 微信小程序登录。客户端使用`wx.login()`获取`code`后调用此接口。 +- **请求体**: + ```json + { + "code": "wx-login-code-from-miniprogram" + } + ``` +- **成功响应 (`200 OK`)**: + - **描述**: 返回JWT和新/老用户信息。 + - **响应体**: + ```json + { + "success": true, + "code": 200, + "message": "登录成功", + "data": { + "token": "jwt.string.here", + "isNewUser": false, + "user": { + "id": "user-openid", + "nickname": "张三", + "avatarUrl": "url-to-avatar" + } + } + } + ``` +- **失败响应**: + - `400 Bad Request`: `code`无效或缺失。 + - `500 Internal Server Error`: 微信服务器接口调用失败。 + +#### 3.1.2 `PUT /auth/profile` +- **功能**: 更新用户信息(昵称、头像)。 +- **认证**: 需要JWT。 +- **请求体**: + ```json + { + "nickname": "新的昵称", + "avatarUrl": "新的头像URL" + } + ``` +- **成功响应 (`200 OK`)**: + - **描述**: 返回更新后的用户信息。 + - **响应体**: + ```json + { + "success": true, + "code": 200, + "message": "用户信息更新成功", + "data": { + "id": "user-openid", + "nickname": "新的昵称", + "avatarUrl": "新的头像URL" + // ... + } + } + ``` + +### 3.2 用户信息模块 (`/api/v1/users`) + +#### 3.2.1 `GET /users/me` +- **功能**: 获取当前登录用户的详细信息。 +- **认证**: 需要JWT。 +- **成功响应 (`200 OK`)**: + - **描述**: 返回包含统计数据的完整用户信息。 + - **响应体**: + ```json + { + "success": true, + "code": 200, + "message": "获取成功", + "data": { + "id": "user-openid", + "nickname": "张三", + "avatarUrl": "url-to-avatar", + "stats": { + "gamesPlayed": 100, + "gamesWon": 60, + "winRate": 60.0, + "eloRating": 1350 + }, + "createdAt": "2024-09-10T..." + } + } + ``` + +#### 3.2.2 `GET /users/:id` +- **功能**: 获取指定ID用户的公开信息。 +- **认证**: 可选。 +- **成功响应 (`200 OK`)**: + - **描述**: 返回用户的公开信息(不含敏感数据)。 + - **响应体**: + ```json + { + "success": true, + "code": 200, + "message": "获取成功", + "data": { + "id": "other-user-id", + "nickname": "李四", + "avatarUrl": "url-to-avatar", + "stats": { + "gamesPlayed": 120, + "winRate": 55.0, + "eloRating": 1300 + } + } + } + ``` +- **失败响应**: + - `404 Not Found`: 用户不存在。 + +### 3.3 游戏模块 (`/api/v1/games`) + +#### 3.3.1 `GET /games/history` +- **功能**: 获取当前用户的历史对局列表(分页)。 +- **认证**: 需要JWT。 +- **查询参数**: + - `page` (number, default: 1): 页码。 + - `limit` (number, default: 10): 每页数量。 +- **成功响应 (`200 OK`)**: + - **描述**: 返回分页的游戏历史记录。 + - **响应体**: + ```json + { + "success": true, + "code": 200, + "message": "获取成功", + "data": { + "games": [ + { + "gameId": "game-id-1", + "opponent": { "id": "user-id-2", "nickname": "李四" }, + "result": "win", // "win" | "loss" + "finishedAt": "2024-09-11T...", + "duration": 600 // 秒 + } + ], + "pagination": { + "currentPage": 1, + "totalPages": 10, + "totalGames": 100 + } + } + } + ``` + +#### 3.3.2 `GET /games/:id` +- **功能**: 获取指定游戏对局的详细信息(用于复盘)。 +- **认证**: 需要JWT,且用户必须是该对局的参与者。 +- **成功响应 (`200 OK`)**: + - **描述**: 返回完整的游戏会话数据。 + - **响应体**: (完整的 `GameSession` 对象) +- **失败响应**: + - `403 Forbidden`: 无权访问该对局。 + - `404 Not Found`: 游戏不存在。 + +### 3.4 排行榜模块 (`/api/v1/leaderboard`) + +#### 3.4.1 `GET /leaderboard` +- **功能**: 获取Elo积分排行榜。 +- **认证**: 可选。 +- **查询参数**: + - `limit` (number, default: 100): 返回的条目数。 +- **成功响应 (`200 OK`)**: + - **描述**: 返回排名列表。 + - **响应体**: + ```json + { + "success": true, + "code": 200, + "message": "获取成功", + "data": { + "ranking": [ + { + "rank": 1, + "user": { "id": "user-id-1", "nickname": "高手" }, + "eloRating": 2000 + }, + { + "rank": 2, + "user": { "id": "user-id-2", "nickname": "大师" }, + "eloRating": 1950 + } + ], + "lastUpdatedAt": "2024-09-11T..." + } + } + ``` + +## 4. WebSocket API + +WebSocket通信不属于RESTful API,但其认证和初始状态获取与HTTP API紧密相关。 + +- **连接地址**: `wss://your-domain.com/ws` +- **认证流程**: + 1. 客户端通过`POST /api/v1/auth/login`获取JWT。 + 2. 建立WebSocket连接。 + 3. 连接成功后,发送第一条消息进行认证。 +- **认证消息**: + ```json + { + "type": "AUTHENTICATE", + "payload": { + "token": "jwt.string.here" + } + } + ``` +- **认证成功响应**: + ```json + { + "type": "AUTHENTICATED", + "payload": { + "userId": "user-id", + // ... + } + } + ``` +- **后续通信**: 详见《实时通信模块详设文档》。 \ No newline at end of file diff --git a/02_详细设计文档/UI组件设计详设.md b/02_详细设计文档/UI组件设计详设.md new file mode 100644 index 0000000..237be87 --- /dev/null +++ b/02_详细设计文档/UI组件设计详设.md @@ -0,0 +1,251 @@ +# UI组件设计详设文档 + +> **文档版本**: v1.0 +> **撰写人**: UI/UX设计师 & 前端架构师 +> **创建日期**: 2024年9月11日 + +## 1. 设计系统与主题 + +### 1.1 设计语言 + +- **主题**: 暗黑科技风 (Dark/Tech Theme),与原型设计稿保持一致。 +- **主色调**: + - 背景: `#1A202C` (深灰蓝) + - 主色: `#00BFFF` (深天蓝,用于按钮、高亮、链接) + - 辅助色: `#4A5568` (中灰,用于边框、分割线) + - 强调色: `#FF4500` (橙红,用于警告、错误提示、攻击命中) +- **字体**: + - `sans-serif` 系统默认无衬线字体。 + - 使用 `rem` 和 `em` 作为字体单位,以支持可访问性。 + +### 1.2 Design Token + +所有颜色、字体大小、间距、圆角等样式参数都将通过Design Token进行管理,以`Sass`变量形式实现。 + +```scss +// src/styles/tokens.scss + +// Colors +$color-background: #1A202C; +$color-primary: #00BFFF; +$color-secondary: #4A5568; +$color-accent: #FF4500; +$color-text-primary: #FFFFFF; +$color-text-secondary: #A0AEC0; + +// Spacing +$spacing-xs: 0.25rem; // 4px +$spacing-sm: 0.5rem; // 8px +$spacing-md: 1rem; // 16px +$spacing-lg: 1.5rem; // 24px +$spacing-xl: 2rem; // 32px + +// Border Radius +$border-radius-sm: 4px; +$border-radius-md: 8px; +$border-radius-lg: 16px; +``` + +## 2. 原子组件 (Atoms) + +原子组件是UI构成的最小单元,不可再分。 + +### 2.1 `Button` 组件 + +- **功能**: 标准按钮,支持不同变体和状态。 +- **Props**: + ```typescript + interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger'; // 样式变体 + size?: 'sm' | 'md' | 'lg'; // 尺寸 + isLoading?: boolean; // 加载状态 + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + } + ``` +- **样式**: + - `primary`: 主色背景,白色文字。 + - `secondary`: 辅助色边框,主色文字。 + - `danger`: 强调色背景,白色文字。 + - `disabled`: 降低透明度,禁用鼠标事件。 + - `isLoading`: 显示加载动画,禁用按钮。 + +### 2.2 `Input` 组件 + +- **功能**: 文本输入框。 +- **Props**: + ```typescript + interface InputProps extends React.InputHTMLAttributes { + label?: string; // 标签 + errorMessage?: string; // 错误信息 + leftIcon?: React.ReactNode; + } + ``` +- **样式**: + - `focus`: 主色边框高亮。 + - `error`: 强调色边框和错误信息。 + +### 2.3 `Spinner` 组件 + +- **功能**: 加载指示器。 +- **Props**: + ```typescript + interface SpinnerProps { + size?: 'sm' | 'md' | 'lg'; // 尺寸 + color?: string; // 颜色 + } + ``` + +### 2.4 `Icon` 组件 + +- **功能**: 图标库封装,使用`react-icons`。 +- **Props**: + ```typescript + import { IconType } from 'react-icons'; + + interface IconProps { + as: IconType; // 图标组件 + size?: string | number; + color?: string; + } + ``` + +## 3. 分子组件 (Molecules) + +由原子组件组合而成,完成特定功能。 + +### 3.1 `FormField` 组件 + +- **构成**: `Input` + `Label` + `ErrorMessage` +- **功能**: 完整的表单字段,包含标签和验证逻辑。 + +### 3.2 `PlayerAvatar` 组件 + +- **构成**: `Avatar` + `Text` +- **功能**: 显示玩家头像和昵称。 +- **Props**: + ```typescript + interface PlayerAvatarProps { + player: { + avatarUrl: string; + nickname: string; + isOwner?: boolean; // 是否是房主 + isReady?: boolean; // 是否已准备 + }; + size?: 'md' | 'lg'; + } + ``` +- **样式**: + - `isReady`: 头像外圈显示主色高亮。 + - `isOwner`: 昵称旁显示皇冠图标。 + +### 3.3 `RoomListItem` 组件 + +- **构成**: `Text` + `Icon` + `Button` +- **功能**: 在房间列表中显示单个房间的信息。 +- **Props**: + ```typescript + interface RoomListItemProps { + room: { + roomCode: string; + name: string; + playerCount: number; + isLocked: boolean; + }; + onJoin: (roomCode: string) => void; + } + ``` + +## 4. 生物组件 (Organisms) + +由分子和原子组件构成的更复杂的UI部分。 + +### 4.1 `GameBoard` 组件 + +- **功能**: 核心游戏棋盘UI,负责渲染10x10的网格和飞机。 +- **构成**: 多个 `GridCell` 组件。 +- **Props**: + ```typescript + interface GameBoardProps { + boardState: BoardState; // 棋盘状态数据 + isMyBoard: boolean; // 是否是自己的棋盘 + onCellClick: (position: { x: number, y: number }) => void; // 单元格点击事件 + placedPlanes?: Plane[]; // 预放置的飞机 + } + ``` +- **`GridCell` 子组件**: + - 根据单元格状态(`empty`, `plane_part`, `miss`, `hit`, `destroy`)显示不同样式。 + - 在对手棋盘上,`plane_part`状态默认不显示,除非被攻击过。 + - 鼠标悬停在可攻击的单元格上时显示准星光标。 + +### 4.2 `PlanePlacementPanel` 组件 + +- **功能**: 飞机布置阶段的操作面板,允许玩家拖拽或旋转飞机。 +- **构成**: `PlanePreview` 组件 + `Button` (旋转/确认) +- **Props**: + ```typescript + interface PlanePlacementPanelProps { + onPlanesConfirm: (planes: Plane[]) => void; + } + ``` +- **交互**: + - 提供3个默认形状的飞机供拖拽到棋盘。 + - 支持点击飞机进行90度旋转。 + - 棋盘上会实时预览飞机位置,并对非法位置(越界、重叠)进行红色高亮提示。 + +### 4.3 `Header` 组件 + +- **功能**: 应用的全局顶部导航栏。 +- **构成**: `Logo` + `UserInfo` + `SettingsButton` + +## 5. 模板组件 (Templates) + +定义页面的整体布局结构。 + +### 5.1 `MainLayout` 组件 + +- **功能**: 应用的主布局,包含页头、内容区、页脚。 +- **构成**: `Header` + `children` + `Footer` +- **插槽(Slots)**: 通过`children` prop 插入页面具体内容。 + +## 6. 页面组件 (Pages) + +完整的页面,由模板和多个生物/分子组件构成。 + +### 6.1 `HomePage` + +- **路径**: `/` +- **功能**: 游戏入口页面,包含"创建房间"和"加入房间"按钮。 +- **组件构成**: `MainLayout`, `Logo`, `Button`。 + +### 6.2 `RoomListPage` + +- **路径**: `/rooms` +- **功能**: 显示公开的房间列表,支持刷新和搜索。 +- **组件构成**: `MainLayout`, `RoomListItem`, `Spinner`, `Button`。 + +### 6.3 `RoomWaitingPage` + +- **路径**: `/room/:roomCode` +- **功能**: 玩家等待页面,显示双方玩家信息和准备状态。 +- **组件构成**: `MainLayout`, `PlayerAvatar` (x2), `Button` (准备/开始游戏), `ChatBox` (可选)。 +- **逻辑**: + - 房主显示"开始游戏"按钮,当所有玩家准备好后激活。 + - 其他玩家显示"准备"按钮。 + - 实时监听WebSocket的`ROOM_STATE_UPDATE`事件更新UI。 + +### 6.4 `GamePage` + +- **路径**: `/game/:gameId` +- **功能**: 核心游戏对战页面。 +- **组件构成**: + - `MainLayout` + - `GameBoard` (我方棋盘) + - `GameBoard` (敌方棋盘) + - `GameStatusPanel`: 显示当前回合、倒计时、游戏日志。 + - `PlanePlacementPanel`: 仅在飞机布置阶段显示。 +- **逻辑**: + - 页面加载时从Zustand Store获取游戏状态。 + - 监听WebSocket的`GAME_STATE_UPDATE`事件更新整个页面。 + - 根据游戏阶段(`placing`, `battling`, `finished`)渲染不同UI。 + - 在`finished`阶段,显示游戏结果弹窗(`GameResultModal`)。 \ No newline at end of file diff --git a/02_详细设计文档/前端技术架构详设.md b/02_详细设计文档/前端技术架构详设.md new file mode 100644 index 0000000..5de8f13 --- /dev/null +++ b/02_详细设计文档/前端技术架构详设.md @@ -0,0 +1,1027 @@ +# 前端技术架构详设文档 + +> **文档版本**: v1.0 +> **撰写人**: 前端架构师 +> **创建日期**: 2024年9月11日 + +## 1. 前端架构总览 + +### 1.1 技术栈详细说明 + +```typescript +// 核心技术栈配置 +{ + "framework": "Taro 4.x", // 跨端开发框架 + "ui": "React 18.2.0", // UI框架 + "language": "TypeScript 5.0+", // 开发语言 + "state": "Zustand 4.4.0", // 状态管理 + "css": "Sass + CSS Modules", // 样式方案 + "build": "Webpack 5 + SWC", // 构建工具 + "lint": "ESLint + Prettier", // 代码规范 + "test": "Jest + Testing Library" // 测试框架 +} +``` + +### 1.2 项目目录结构 + +``` +src/ +├── components/ # 通用组件 +│ ├── GameBoard/ # 游戏棋盘组件 +│ ├── PlaneShape/ # 飞机形状组件 +│ ├── Modal/ # 模态框组件 +│ └── Loading/ # 加载组件 +├── pages/ # 页面组件 +│ ├── entry/ # 入口页面 +│ ├── room/ # 房间相关页面 +│ └── game/ # 游戏页面 +├── hooks/ # 自定义Hooks +│ ├── useGame.ts # 游戏逻辑Hook +│ ├── useWebSocket.ts # WebSocket Hook +│ └── useAuth.ts # 认证Hook +├── store/ # 状态管理 +│ ├── gameStore.ts # 游戏状态 +│ ├── userStore.ts # 用户状态 +│ └── roomStore.ts # 房间状态 +├── services/ # 业务服务 +│ ├── api.ts # API封装 +│ ├── websocket.ts # WebSocket服务 +│ └── storage.ts # 本地存储 +├── utils/ # 工具函数 +│ ├── gameLogic.ts # 游戏逻辑工具 +│ ├── helpers.ts # 通用工具 +│ └── constants.ts # 常量定义 +└── styles/ # 样式文件 + ├── variables.scss # 变量定义 + ├── mixins.scss # 混合器 + └── global.scss # 全局样式 +``` + +## 2. 状态管理架构 + +### 2.1 Zustand Store 设计 + +```typescript +// 用户状态Store +interface UserState { + user: User | null + isAuthenticated: boolean + login: (userInfo: WxUserInfo) => Promise + logout: () => void + updateProfile: (profile: Partial) => void +} + +export const useUserStore = create((set, get) => ({ + user: null, + isAuthenticated: false, + + login: async (userInfo: WxUserInfo) => { + try { + const user = await apiService.login(userInfo) + set({ user, isAuthenticated: true }) + // 持久化存储 + await storage.setUser(user) + } catch (error) { + throw new Error('登录失败') + } + }, + + logout: () => { + set({ user: null, isAuthenticated: false }) + storage.clearUser() + }, + + updateProfile: (profile: Partial) => { + const currentUser = get().user + if (currentUser) { + set({ user: { ...currentUser, ...profile } }) + } + } +})) + +// 游戏状态Store +interface GameState { + // 当前游戏状态 + currentGame: GameSession | null + gamePhase: GamePhase + currentPlayer: string + myBoard: GameBoard + opponentBoard: GameBoard + + // 操作方法 + initGame: (gameData: GameInitData) => void + placePlane: (plane: PlaneData) => boolean + attack: (position: Position) => void + updateGameState: (state: GameStateUpdate) => void + resetGame: () => void +} + +export const useGameStore = create((set, get) => ({ + currentGame: null, + gamePhase: GamePhase.WAITING, + currentPlayer: '', + myBoard: createEmptyBoard(), + opponentBoard: createEmptyBoard(), + + initGame: (gameData: GameInitData) => { + set({ + currentGame: gameData.session, + gamePhase: GamePhase.PLACING, + currentPlayer: gameData.firstPlayer, + myBoard: createEmptyBoard(), + opponentBoard: createEmptyBoard() + }) + }, + + placePlane: (plane: PlaneData): boolean => { + const state = get() + const newBoard = GameLogic.placePlane(state.myBoard, plane) + + if (newBoard) { + set({ myBoard: newBoard }) + return true + } + return false + }, + + attack: (position: Position) => { + const state = get() + if (state.gamePhase !== GamePhase.BATTLING) return + + // 发送攻击请求 + websocketService.sendAttack(position) + }, + + updateGameState: (update: GameStateUpdate) => { + set(state => ({ + ...state, + ...update + })) + }, + + resetGame: () => { + set({ + currentGame: null, + gamePhase: GamePhase.WAITING, + currentPlayer: '', + myBoard: createEmptyBoard(), + opponentBoard: createEmptyBoard() + }) + } +})) + +// 房间状态Store +interface RoomState { + currentRoom: Room | null + availableRooms: Room[] + connectionStatus: ConnectionStatus + + createRoom: () => Promise + joinRoom: (roomCode: string) => Promise + leaveRoom: () => void + refreshRoomList: () => Promise +} + +export const useRoomStore = create((set, get) => ({ + currentRoom: null, + availableRooms: [], + connectionStatus: ConnectionStatus.DISCONNECTED, + + createRoom: async (): Promise => { + const room = await apiService.createRoom() + set({ currentRoom: room }) + // 连接WebSocket + await websocketService.joinRoom(room.code) + return room + }, + + joinRoom: async (roomCode: string): Promise => { + try { + const room = await apiService.joinRoom(roomCode) + set({ currentRoom: room }) + await websocketService.joinRoom(roomCode) + return true + } catch (error) { + return false + } + }, + + leaveRoom: () => { + const room = get().currentRoom + if (room) { + websocketService.leaveRoom(room.code) + set({ currentRoom: null }) + } + }, + + refreshRoomList: async () => { + const rooms = await apiService.getRoomList() + set({ availableRooms: rooms }) + } +})) +``` + +### 2.2 状态持久化策略 + +```typescript +// 状态持久化中间件 +const persistMiddleware = ( + config: StateCreator, + options: { + name: string + storage: Storage + partialize?: (state: T) => Partial + } +) => (set: any, get: any, api: any) => + config( + (partial, replace) => { + const nextState = typeof partial === 'function' ? partial(get()) : partial + const stateToStore = options.partialize ? options.partialize(nextState) : nextState + + // 更新状态 + set(partial, replace) + + // 持久化存储 + options.storage.setItem(options.name, JSON.stringify(stateToStore)) + }, + get, + api + ) + +// 使用示例 +export const useUserStore = create()( + persistMiddleware( + (set, get) => ({ + // 状态定义... + }), + { + name: 'user-store', + storage: wx.getStorageSync ? wxStorage : localStorage, + partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }) + } + ) +) +``` + +## 3. 组件设计架构 + +### 3.1 基础组件设计 + +```typescript +// 游戏棋盘组件 +interface GameBoardProps { + board: GameBoard + mode: 'placement' | 'battle' | 'readonly' + onCellClick?: (position: Position) => void + onPlanePlace?: (plane: PlaneData) => void + showAttackResults?: boolean + className?: string +} + +export const GameBoard: React.FC = ({ + board, + mode, + onCellClick, + onPlanePlace, + showAttackResults = false, + className = '' +}) => { + const [selectedCell, setSelectedCell] = useState(null) + const [dragState, setDragState] = useState(null) + + // 处理单元格点击 + const handleCellClick = useCallback((position: Position) => { + if (mode === 'readonly') return + + if (mode === 'placement') { + handlePlacementClick(position) + } else if (mode === 'battle') { + handleBattleClick(position) + } + }, [mode, onCellClick, onPlanePlace]) + + // 处理飞机放置 + const handlePlacementClick = (position: Position) => { + // 飞机放置逻辑 + if (onPlanePlace) { + const plane = createPlaneAt(position, currentDirection) + onPlanePlace(plane) + } + } + + // 处理攻击点击 + const handleBattleClick = (position: Position) => { + if (onCellClick) { + onCellClick(position) + } + } + + return ( +
+ {board.cells.map((row, x) => + row.map((cell, y) => ( + + )) + )} +
+ ) +} + +// 棋盘单元格组件 +interface BoardCellProps { + cell: BoardCell + position: Position + isSelected: boolean + onClick: (position: Position) => void + showAttackResult: boolean +} + +const BoardCell: React.FC = ({ + cell, + position, + isSelected, + onClick, + showAttackResult +}) => { + const handleClick = () => onClick(position) + + const cellClass = [ + 'board-cell', + cell.state, + isSelected && 'selected', + cell.planeId && 'has-plane' + ].filter(Boolean).join(' ') + + return ( +
+ {showAttackResult && cell.attackResult && ( +
+ {cell.attackResult.type === 'HIT' ? '🎯' : '💥'} +
+ )} + {cell.planeId && ( +
+ )} +
+ ) +} +``` + +### 3.2 页面组件架构 + +```typescript +// 游戏对战页面 +const BattlePage: React.FC = () => { + const gameState = useGameStore() + const userState = useUserStore() + const { connectWebSocket, sendMessage } = useWebSocket() + + // 游戏初始化 + useEffect(() => { + initializeGame() + }, []) + + // WebSocket消息处理 + useEffect(() => { + const handleMessage = (message: GameMessage) => { + switch (message.type) { + case 'GAME_STATE_UPDATE': + gameState.updateGameState(message.data) + break + case 'ATTACK_RESULT': + handleAttackResult(message.data) + break + case 'PLAYER_DISCONNECTED': + handlePlayerDisconnect(message.data) + break + } + } + + connectWebSocket(handleMessage) + }, []) + + const initializeGame = async () => { + try { + const gameData = await apiService.getGameState(gameState.currentGame?.id) + gameState.initGame(gameData) + } catch (error) { + showError('游戏初始化失败') + } + } + + const handleAttack = (position: Position) => { + gameState.attack(position) + } + + const handleAttackResult = (result: AttackResult) => { + gameState.updateGameState({ + opponentBoard: applyAttackResult(gameState.opponentBoard, result) + }) + + if (result.gameEnded) { + showGameResult(result.winner) + } + } + + return ( +
+ + +
+
+

我的棋盘

+ +
+ +
+

对手棋盘

+ +
+
+ + gameState.surrender()} + /> +
+ ) +} +``` + +## 4. 自定义Hooks设计 + +### 4.1 游戏逻辑Hooks + +```typescript +// 游戏逻辑Hook +export const useGame = () => { + const gameStore = useGameStore() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // 开始游戏 + const startGame = async (roomCode: string) => { + setLoading(true) + setError(null) + + try { + const gameData = await apiService.startGame(roomCode) + gameStore.initGame(gameData) + } catch (err) { + setError('游戏启动失败') + } finally { + setLoading(false) + } + } + + // 放置飞机 + const placePlane = (plane: PlaneData) => { + const success = gameStore.placePlane(plane) + if (!success) { + setError('飞机放置失败,请检查位置') + return false + } + + // 如果已放置3架飞机,自动进入准备状态 + if (gameStore.myBoard.planes.length === 3) { + confirmPlacement() + } + + return true + } + + // 确认飞机布置 + const confirmPlacement = async () => { + try { + await apiService.confirmPlacement(gameStore.myBoard.planes) + gameStore.updateGameState({ gamePhase: GamePhase.WAITING_OPPONENT }) + } catch (err) { + setError('确认布置失败') + } + } + + // 执行攻击 + const attack = async (position: Position) => { + if (gameStore.gamePhase !== GamePhase.BATTLING) return + + try { + const result = await apiService.attack(position) + gameStore.updateGameState({ + opponentBoard: applyAttackResult(gameStore.opponentBoard, result) + }) + + if (result.gameEnded) { + gameStore.updateGameState({ gamePhase: GamePhase.FINISHED }) + } + } catch (err) { + setError('攻击失败') + } + } + + return { + ...gameStore, + loading, + error, + startGame, + placePlane, + confirmPlacement, + attack, + clearError: () => setError(null) + } +} + +// WebSocket Hook +export const useWebSocket = () => { + const [socket, setSocket] = useState(null) + const [connectionState, setConnectionState] = useState('disconnected') + const [lastMessage, setLastMessage] = useState(null) + + // 连接WebSocket + const connect = useCallback((onMessage: (message: GameMessage) => void) => { + if (socket?.readyState === WebSocket.OPEN) return + + const ws = new WebSocketConnection({ + url: WS_URL, + onOpen: () => setConnectionState('connected'), + onClose: () => setConnectionState('disconnected'), + onError: () => setConnectionState('error'), + onMessage: (message) => { + setLastMessage(message) + onMessage(message) + } + }) + + setSocket(ws) + setConnectionState('connecting') + }, [socket]) + + // 发送消息 + const sendMessage = useCallback((message: GameMessage) => { + if (socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(message)) + } + }, [socket]) + + // 断线重连 + useEffect(() => { + let reconnectTimer: NodeJS.Timeout + + if (connectionState === 'disconnected') { + reconnectTimer = setTimeout(() => { + if (socket) { + connect(() => {}) // 重连 + } + }, 3000) + } + + return () => clearTimeout(reconnectTimer) + }, [connectionState, connect]) + + // 清理连接 + useEffect(() => { + return () => { + if (socket) { + socket.close() + } + } + }, []) + + return { + connectionState, + lastMessage, + connect, + sendMessage, + disconnect: () => socket?.close() + } +} +``` + +### 4.2 UI交互Hooks + +```typescript +// 模态框Hook +export const useModal = () => { + const [isOpen, setIsOpen] = useState(false) + const [title, setTitle] = useState('') + const [content, setContent] = useState(null) + + const openModal = (modalTitle: string, modalContent: React.ReactNode) => { + setTitle(modalTitle) + setContent(modalContent) + setIsOpen(true) + } + + const closeModal = () => { + setIsOpen(false) + } + + const Modal = ({ children }: { children?: React.ReactNode }) => { + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+ {content || children} +
+
+
+ ) + } + + return { + isOpen, + openModal, + closeModal, + Modal + } +} + +// 加载状态Hook +export const useLoading = () => { + const [loading, setLoading] = useState(false) + const [loadingText, setLoadingText] = useState('加载中...') + + const showLoading = (text = '加载中...') => { + setLoadingText(text) + setLoading(true) + } + + const hideLoading = () => { + setLoading(false) + } + + const LoadingComponent = () => { + if (!loading) return null + + return ( +
+
+
+

{loadingText}

+
+
+ ) + } + + return { + loading, + showLoading, + hideLoading, + LoadingComponent + } +} +``` + +## 5. 样式架构设计 + +### 5.1 设计Token系统 + +```scss +// styles/variables.scss - 设计变量定义 +:root { + // 颜色系统 - 基于原型的深色科技主题 + --color-primary: #6366f1; + --color-secondary: #40e0d0; + --color-accent: #ff6b6b; + --color-success: #51cf66; + --color-warning: #ffd43b; + --color-danger: #ff6b6b; + + // 背景色 + --bg-primary: #0f1419; + --bg-secondary: #1a1a2e; + --bg-card: #16213e; + --bg-overlay: rgba(15, 20, 25, 0.9); + + // 渐变背景 + --gradient-primary: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 30%, #16213e 70%, #0f3460 100%); + --gradient-card: linear-gradient(145deg, #1e2a47 0%, #2a3f5f 100%); + + // 文字颜色 + --text-primary: #ffffff; + --text-secondary: #b8c5d3; + --text-muted: #6c7983; + --text-disabled: #4a5568; + + // 边框和分割线 + --border-color: #2d3748; + --border-hover: #4a5568; + --divider: #2d3748; + + // 阴影 + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.16); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.2); + --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.3); + + // 间距系统 + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-xxl: 48px; + + // 字体大小 + --font-xs: 12px; + --font-sm: 14px; + --font-base: 16px; + --font-lg: 18px; + --font-xl: 20px; + --font-2xl: 24px; + --font-3xl: 30px; + + // 圆角 + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 50%; + + // Z-index层级 + --z-modal: 1000; + --z-loading: 999; + --z-toast: 998; + --z-header: 100; +} +``` + +### 5.2 组件样式库 + +```scss +// styles/components.scss - 组件样式 +.game-board { + display: grid; + grid-template-columns: repeat(10, 1fr); + grid-template-rows: repeat(10, 1fr); + gap: 2px; + padding: var(--space-md); + background: var(--gradient-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + + &.placement { + .board-cell { + cursor: pointer; + + &:hover { + background: var(--color-primary); + opacity: 0.7; + } + } + } + + &.battle { + .board-cell { + cursor: crosshair; + + &:hover:not(.attacked) { + background: var(--color-accent); + opacity: 0.8; + } + } + } +} + +.board-cell { + aspect-ratio: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + position: relative; + transition: all 0.2s ease; + + &.has-plane { + background: var(--color-secondary); + } + + &.attacked-miss { + background: var(--bg-card); + + &::after { + content: '○'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: var(--font-sm); + } + } + + &.attacked-hit { + background: var(--color-danger); + + &::after { + content: '✕'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-primary); + font-size: var(--font-base); + font-weight: bold; + } + } + + &.selected { + background: var(--color-primary); + box-shadow: var(--shadow-glow); + } +} + +.attack-result { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: var(--font-lg); + animation: attackResult 0.8s ease-out; + + &.HIT { + color: var(--color-success); + } + + &.MISS { + color: var(--text-muted); + } + + &.DESTROY { + color: var(--color-danger); + font-size: var(--font-xl); + } +} + +@keyframes attackResult { + 0% { + transform: translate(-50%, -50%) scale(0); + opacity: 0; + } + 50% { + transform: translate(-50%, -50%) scale(1.5); + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } +} +``` + +## 6. 性能优化策略 + +### 6.1 渲染优化 + +```typescript +// 使用React.memo优化组件渲染 +export const GameBoard = React.memo(({ + board, + mode, + onCellClick, + onPlanePlace +}) => { + // 只有相关props变化时才重渲染 +}, (prevProps, nextProps) => { + return ( + prevProps.board === nextProps.board && + prevProps.mode === nextProps.mode && + prevProps.onCellClick === nextProps.onCellClick && + prevProps.onPlanePlace === nextProps.onPlanePlace + ) +}) + +// 使用useMemo缓存计算结果 +const BoardCell = React.memo(({ cell, position, onClick }) => { + const cellClass = useMemo(() => { + return [ + 'board-cell', + cell.state, + cell.planeId && 'has-plane' + ].filter(Boolean).join(' ') + }, [cell.state, cell.planeId]) + + const handleClick = useCallback(() => { + onClick(position) + }, [onClick, position]) + + return ( +
+ {/* 单元格内容 */} +
+ ) +}) +``` + +### 6.2 状态更新优化 + +```typescript +// 批量状态更新 +class GameStateManager { + private updateQueue: StateUpdate[] = [] + private isUpdating = false + + queueUpdate(update: StateUpdate) { + this.updateQueue.push(update) + if (!this.isUpdating) { + this.flushUpdates() + } + } + + private async flushUpdates() { + this.isUpdating = true + + // 合并所有更新 + const mergedUpdate = this.updateQueue.reduce((merged, update) => { + return { ...merged, ...update } + }, {}) + + // 批量应用更新 + useGameStore.setState(state => ({ + ...state, + ...mergedUpdate + })) + + this.updateQueue = [] + this.isUpdating = false + } +} +``` + +## 7. 错误处理机制 + +### 7.1 错误边界设计 + +```typescript +// 游戏错误边界 +class GameErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: any) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('游戏错误:', error, errorInfo) + + // 上报错误 + this.reportError(error, errorInfo) + } + + private reportError(error: Error, errorInfo: ErrorInfo) { + // 调用错误上报服务 + errorReportingService.log({ + message: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack + }) + } + + render() { + if (this.state.hasError) { + return ( +
+

游戏出错了

+

我们已经记录了错误信息,请稍后重试。

+ +
+ ) + } + + return this.props.children + } +} \ No newline at end of file diff --git a/02_详细设计文档/后端技术架构详设.md b/02_详细设计文档/后端技术架构详设.md new file mode 100644 index 0000000..38b5bb9 --- /dev/null +++ b/02_详细设计文档/后端技术架构详设.md @@ -0,0 +1,887 @@ +# 后端技术架构详设文档 + +> **文档版本**: v1.0 +> **撰写人**: 后端架构师 +> **创建日期**: 2024年9月11日 + +## 1. 后端架构总览 + +### 1.1 技术栈详细说明 + +```typescript +// 后端技术栈配置 +{ + "runtime": "Node.js 18+", // 运行环境 + "framework": "Fastify 4.x", // Web框架 + "language": "TypeScript 5.0+", // 开发语言 + "database": "MongoDB 6.0", // 主数据库 + "cache": "Redis 7.0", // 缓存数据库 + "websocket": "ws + socket.io", // WebSocket库 + "orm": "Mongoose 7.x", // ODM工具 + "validation": "Joi", // 数据验证 + "auth": "JWT + 微信登录", // 认证方案 + "logging": "Winston + Morgan", // 日志系统 + "testing": "Jest + Supertest", // 测试框架 + "process": "PM2", // 进程管理 + "containerization": "Docker" // 容器化 +} +``` + +### 1.2 服务架构设计 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Load Balancer │ │ Gateway │ │ Static CDN │ +│ (Nginx) │ │ (API Route) │ │ (Assets) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └───────────────────────┼───────────────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + │ Application │ + │ Layer (Fastify) │ + └───────────────────────┬───────────────────────┘ + │ + ┌────────────────────────────┼────────────────────────────┐ + │ │ │ +┌───▼────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐ +│ Auth │ │ Game Service │ │ WebSocket │ +│Service │ │ (Core Logic) │ │ Service │ +└────────┘ └───────────────────┘ └───────────────────┘ + │ │ │ + └───────────────────┼────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ +┌───▼─────┐ ┌───────▼──────┐ ┌──────▼─────┐ +│MongoDB │ │ Redis │ │ External │ +│(主数据) │ │ (缓存) │ │ APIs │ +└─────────┘ └──────────────┘ └────────────┘ +``` + +### 1.3 项目目录结构 + +``` +src/ +├── app.ts # 应用入口 +├── server.ts # 服务器启动 +├── config/ # 配置文件 +│ ├── database.ts # 数据库配置 +│ ├── redis.ts # Redis配置 +│ ├── jwt.ts # JWT配置 +│ └── environment.ts # 环境变量 +├── controllers/ # 控制器层 +│ ├── auth.controller.ts # 认证控制器 +│ ├── room.controller.ts # 房间控制器 +│ ├── game.controller.ts # 游戏控制器 +│ └── user.controller.ts # 用户控制器 +├── services/ # 业务逻辑层 +│ ├── auth.service.ts # 认证服务 +│ ├── room.service.ts # 房间服务 +│ ├── game.service.ts # 游戏服务 +│ ├── user.service.ts # 用户服务 +│ └── websocket.service.ts # WebSocket服务 +├── models/ # 数据模型 +│ ├── User.ts # 用户模型 +│ ├── Room.ts # 房间模型 +│ ├── Game.ts # 游戏模型 +│ └── GameSession.ts # 游戏会话模型 +├── middleware/ # 中间件 +│ ├── auth.middleware.ts # 认证中间件 +│ ├── validation.middleware.ts # 验证中间件 +│ ├── error.middleware.ts # 错误处理中间件 +│ └── logging.middleware.ts # 日志中间件 +├── routes/ # 路由定义 +│ ├── auth.routes.ts # 认证路由 +│ ├── room.routes.ts # 房间路由 +│ ├── game.routes.ts # 游戏路由 +│ └── user.routes.ts # 用户路由 +├── utils/ # 工具函数 +│ ├── gameLogic.ts # 游戏逻辑工具 +│ ├── encryption.ts # 加密工具 +│ ├── validators.ts # 验证工具 +│ └── helpers.ts # 通用工具 +├── websocket/ # WebSocket处理 +│ ├── handlers/ # 消息处理器 +│ ├── events.ts # 事件定义 +│ └── connection.ts # 连接管理 +└── tests/ # 测试文件 + ├── unit/ # 单元测试 + ├── integration/ # 集成测试 + └── e2e/ # 端到端测试 +``` + +## 2. 核心服务设计 + +### 2.1 Fastify应用配置 + +```typescript +// app.ts - 应用主配置 +import Fastify, { FastifyInstance } from 'fastify' +import fastifyJwt from '@fastify/jwt' +import fastifyWebsocket from '@fastify/websocket' +import fastifyCors from '@fastify/cors' +import fastifyRateLimit from '@fastify/rate-limit' + +export const createApp = async (): Promise => { + const app = Fastify({ + logger: { + level: process.env.LOG_LEVEL || 'info', + transport: process.env.NODE_ENV === 'development' ? { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'HH:MM:ss', + ignore: 'pid,hostname' + } + } : undefined + } + }) + + // 注册插件 + await app.register(fastifyJwt, { + secret: process.env.JWT_SECRET!, + sign: { expiresIn: '7d' } + }) + + await app.register(fastifyWebsocket, { + options: { + maxPayload: 1048576, // 1MB + verifyClient: (info) => { + // WebSocket连接验证 + return verifyWebSocketClient(info) + } + } + }) + + await app.register(fastifyCors, { + origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'], + credentials: true + }) + + await app.register(fastifyRateLimit, { + max: 100, + timeWindow: '1 minute', + errorResponseBuilder: (request, context) => { + return { + code: 429, + error: 'Too Many Requests', + message: `请求过于频繁,请${Math.round(context.ttl / 1000)}秒后重试` + } + } + }) + + // 注册路由 + await app.register(authRoutes, { prefix: '/api/auth' }) + await app.register(userRoutes, { prefix: '/api/users' }) + await app.register(roomRoutes, { prefix: '/api/rooms' }) + await app.register(gameRoutes, { prefix: '/api/games' }) + await app.register(websocketRoutes, { prefix: '/ws' }) + + // 错误处理 + app.setErrorHandler(errorHandler) + app.setNotFoundHandler(notFoundHandler) + + return app +} + +// 健康检查端点 +app.get('/health', async (request, reply) => { + const health = { + status: 'ok', + timestamp: new Date().toISOString(), + services: { + database: await checkDatabaseHealth(), + redis: await checkRedisHealth(), + websocket: checkWebSocketHealth() + } + } + + reply.send(health) +}) +``` + +### 2.2 认证服务设计 + +```typescript +// services/auth.service.ts +import jwt from 'jsonwebtoken' +import { User } from '../models/User' +import { redisClient } from '../config/redis' + +export class AuthService { + // 微信小程序登录 + async wxLogin(code: string): Promise<{ user: User; token: string }> { + try { + // 1. 通过code获取openid + const wxSession = await this.getWxSession(code) + + // 2. 查找或创建用户 + let user = await User.findOne({ openid: wxSession.openid }) + if (!user) { + user = await User.create({ + openid: wxSession.openid, + sessionKey: wxSession.session_key, + unionid: wxSession.unionid, + createdAt: new Date() + }) + } else { + // 更新session_key + user.sessionKey = wxSession.session_key + await user.save() + } + + // 3. 生成JWT token + const token = this.generateToken(user._id.toString()) + + // 4. 缓存用户会话 + await this.cacheUserSession(user._id.toString(), token) + + return { user, token } + } catch (error) { + throw new Error('微信登录失败') + } + } + + // 获取微信session + private async getWxSession(code: string): Promise { + const response = await fetch(`https://api.weixin.qq.com/sns/jscode2session`, { + method: 'GET', + params: { + appid: process.env.WX_APPID!, + secret: process.env.WX_APP_SECRET!, + js_code: code, + grant_type: 'authorization_code' + } + }) + + const data = await response.json() + + if (data.errcode) { + throw new Error(`微信API错误: ${data.errmsg}`) + } + + return data + } + + // 生成JWT token + private generateToken(userId: string): string { + return jwt.sign( + { userId, type: 'access' }, + process.env.JWT_SECRET!, + { expiresIn: '7d' } + ) + } + + // 验证token + async verifyToken(token: string): Promise<{ userId: string; valid: boolean }> { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any + + // 检查会话是否仍然有效 + const sessionExists = await redisClient.exists(`session:${decoded.userId}`) + + return { + userId: decoded.userId, + valid: sessionExists === 1 + } + } catch (error) { + return { userId: '', valid: false } + } + } + + // 缓存用户会话 + private async cacheUserSession(userId: string, token: string): Promise { + const sessionData = { + token, + createdAt: new Date().toISOString(), + lastActive: new Date().toISOString() + } + + await redisClient.setex( + `session:${userId}`, + 7 * 24 * 60 * 60, // 7天过期 + JSON.stringify(sessionData) + ) + } + + // 刷新用户活跃时间 + async refreshUserActivity(userId: string): Promise { + const sessionKey = `session:${userId}` + const sessionData = await redisClient.get(sessionKey) + + if (sessionData) { + const session = JSON.parse(sessionData) + session.lastActive = new Date().toISOString() + + await redisClient.setex( + sessionKey, + 7 * 24 * 60 * 60, + JSON.stringify(session) + ) + } + } + + // 用户登出 + async logout(userId: string): Promise { + await redisClient.del(`session:${userId}`) + } +} + +// 认证中间件 +export const authMiddleware = async (request: FastifyRequest, reply: FastifyReply) => { + try { + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return reply.code(401).send({ error: '缺少认证令牌' }) + } + + const token = authHeader.substring(7) + const authService = new AuthService() + const { userId, valid } = await authService.verifyToken(token) + + if (!valid) { + return reply.code(401).send({ error: '无效的认证令牌' }) + } + + // 刷新用户活跃时间 + await authService.refreshUserActivity(userId) + + // 将用户ID添加到请求上下文 + request.user = { id: userId } + } catch (error) { + return reply.code(401).send({ error: '认证失败' }) + } +} +``` + +### 2.3 房间管理服务 + +```typescript +// services/room.service.ts +import { Room } from '../models/Room' +import { User } from '../models/User' +import { redisClient } from '../config/redis' +import { websocketService } from './websocket.service' + +export class RoomService { + // 创建房间 + async createRoom(hostId: string): Promise { + const roomCode = this.generateRoomCode() + + const room = await Room.create({ + code: roomCode, + hostId, + status: 'waiting', + maxPlayers: 2, + currentPlayers: 1, + createdAt: new Date() + }) + + // 缓存房间信息 + await this.cacheRoomData(room) + + // 通知房间列表更新 + await websocketService.broadcastRoomListUpdate() + + return room + } + + // 加入房间 + async joinRoom(roomCode: string, playerId: string): Promise { + const room = await Room.findOne({ code: roomCode }) + + if (!room) { + throw new Error('房间不存在') + } + + if (room.status !== 'waiting') { + throw new Error('房间已开始游戏或已结束') + } + + if (room.currentPlayers >= room.maxPlayers) { + throw new Error('房间已满') + } + + if (room.hostId === playerId) { + throw new Error('不能加入自己创建的房间') + } + + // 更新房间信息 + room.guestId = playerId + room.currentPlayers = 2 + room.status = 'ready' + await room.save() + + // 更新缓存 + await this.cacheRoomData(room) + + // 通知房间内玩家 + await websocketService.notifyRoomUpdate(roomCode, { + type: 'PLAYER_JOINED', + room: room.toObject(), + playerId + }) + + return room + } + + // 离开房间 + async leaveRoom(roomCode: string, playerId: string): Promise { + const room = await Room.findOne({ code: roomCode }) + + if (!room) { + throw new Error('房间不存在') + } + + if (room.hostId === playerId) { + // 房主离开,删除房间 + await Room.deleteOne({ code: roomCode }) + await redisClient.del(`room:${roomCode}`) + + // 通知客人 + if (room.guestId) { + await websocketService.notifyPlayer(room.guestId, { + type: 'ROOM_CLOSED', + message: '房主已离开,房间关闭' + }) + } + } else if (room.guestId === playerId) { + // 客人离开 + room.guestId = undefined + room.currentPlayers = 1 + room.status = 'waiting' + await room.save() + + await this.cacheRoomData(room) + + // 通知房主 + await websocketService.notifyPlayer(room.hostId, { + type: 'PLAYER_LEFT', + room: room.toObject() + }) + } + + // 更新房间列表 + await websocketService.broadcastRoomListUpdate() + } + + // 获取房间列表 + async getRoomList(): Promise { + const rooms = await Room.find({ + status: 'waiting', + currentPlayers: { $lt: 2 } + }).populate('host', 'nickname avatar').lean() + + return rooms + } + + // 获取房间详情 + async getRoomDetails(roomCode: string): Promise { + // 先从缓存获取 + const cachedRoom = await redisClient.get(`room:${roomCode}`) + if (cachedRoom) { + return JSON.parse(cachedRoom) + } + + // 从数据库获取 + const room = await Room.findOne({ code: roomCode }) + .populate('host', 'nickname avatar') + .populate('guest', 'nickname avatar') + .lean() + + if (room) { + await this.cacheRoomData(room) + } + + return room + } + + // 开始游戏 + async startGame(roomCode: string, hostId: string): Promise { + const room = await Room.findOne({ code: roomCode }) + + if (!room) { + throw new Error('房间不存在') + } + + if (room.hostId !== hostId) { + throw new Error('只有房主可以开始游戏') + } + + if (room.currentPlayers < 2) { + throw new Error('等待其他玩家加入') + } + + // 更新房间状态 + room.status = 'playing' + await room.save() + + // 创建游戏会话 + const gameService = new GameService() + const gameSession = await gameService.createGameSession(room) + + // 通知玩家游戏开始 + await websocketService.notifyRoomUpdate(roomCode, { + type: 'GAME_STARTED', + gameSession: gameSession.toObject() + }) + } + + // 生成房间码 + private generateRoomCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let result = '' + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result + } + + // 缓存房间数据 + private async cacheRoomData(room: any): Promise { + await redisClient.setex( + `room:${room.code}`, + 30 * 60, // 30分钟过期 + JSON.stringify(room) + ) + } +} +``` + +### 2.4 游戏核心服务 + +```typescript +// services/game.service.ts +import { GameSession } from '../models/GameSession' +import { Room } from '../models/Room' +import { GameLogic } from '../utils/gameLogic' +import { websocketService } from './websocket.service' + +export class GameService { + // 创建游戏会话 + async createGameSession(room: Room): Promise { + const gameSession = await GameSession.create({ + roomId: room._id, + roomCode: room.code, + players: [room.hostId, room.guestId], + gameState: { + phase: 'PLACING', + currentPlayer: room.hostId, // 房主先开始 + boards: { + [room.hostId]: GameLogic.createEmptyBoard(), + [room.guestId!]: GameLogic.createEmptyBoard() + }, + moves: [], + startedAt: new Date() + }, + createdAt: new Date() + }) + + return gameSession + } + + // 放置飞机 + async placePlanes( + gameSessionId: string, + playerId: string, + planes: PlaneData[] + ): Promise { + const session = await GameSession.findById(gameSessionId) + + if (!session) { + throw new Error('游戏会话不存在') + } + + if (session.gameState.phase !== 'PLACING') { + throw new Error('当前不是飞机放置阶段') + } + + // 验证飞机放置是否合法 + const isValid = GameLogic.validatePlanesPlacement(planes) + if (!isValid) { + throw new Error('飞机放置不合法') + } + + // 更新游戏状态 + session.gameState.boards[playerId] = GameLogic.placePlanesOnBoard( + session.gameState.boards[playerId], + planes + ) + + // 标记玩家已准备 + session.gameState.playersReady = session.gameState.playersReady || {} + session.gameState.playersReady[playerId] = true + + // 检查是否所有玩家都已准备 + const allReady = session.players.every(p => + session.gameState.playersReady[p] + ) + + if (allReady) { + // 开始对战阶段 + session.gameState.phase = 'BATTLING' + session.gameState.battleStartedAt = new Date() + } + + await session.save() + + // 通知所有玩家状态更新 + await websocketService.notifyGameUpdate(session.roomCode, { + type: 'PLACEMENT_UPDATE', + playerId, + ready: true, + allReady, + gameState: session.gameState + }) + } + + // 执行攻击 + async attack( + gameSessionId: string, + attackerId: string, + position: Position + ): Promise { + const session = await GameSession.findById(gameSessionId) + + if (!session) { + throw new Error('游戏会话不存在') + } + + if (session.gameState.phase !== 'BATTLING') { + throw new Error('当前不是对战阶段') + } + + if (session.gameState.currentPlayer !== attackerId) { + throw new Error('不是你的回合') + } + + // 确定被攻击的玩家 + const defenderId = session.players.find(p => p !== attackerId)! + const defenderBoard = session.gameState.boards[defenderId] + + // 执行攻击逻辑 + const attackResult = GameLogic.processAttack(defenderBoard, position) + + // 更新游戏状态 + session.gameState.boards[defenderId] = attackResult.updatedBoard + session.gameState.moves.push({ + playerId: attackerId, + type: 'ATTACK', + position, + result: attackResult.type, + timestamp: new Date() + }) + + // 检查游戏是否结束 + if (attackResult.gameEnded) { + session.gameState.phase = 'FINISHED' + session.gameState.winner = attackerId + session.gameState.endedAt = new Date() + + // 更新玩家统计 + await this.updatePlayerStats(attackerId, defenderId) + } else { + // 切换当前玩家 + session.gameState.currentPlayer = defenderId + } + + await session.save() + + // 通知所有玩家攻击结果 + await websocketService.notifyGameUpdate(session.roomCode, { + type: 'ATTACK_RESULT', + attackerId, + defenderId, + position, + result: attackResult, + gameState: session.gameState + }) + + return attackResult + } + + // 获取游戏状态 + async getGameState(gameSessionId: string, playerId: string): Promise { + const session = await GameSession.findById(gameSessionId) + + if (!session) { + throw new Error('游戏会话不存在') + } + + if (!session.players.includes(playerId)) { + throw new Error('你不在这个游戏中') + } + + // 返回过滤后的游戏状态(隐藏对手飞机位置) + const filteredState = { + ...session.gameState, + boards: { + [playerId]: session.gameState.boards[playerId], + // 对手棋盘只显示攻击结果,不显示飞机位置 + opponent: GameLogic.filterOpponentBoard( + session.gameState.boards[session.players.find(p => p !== playerId)!] + ) + } + } + + return filteredState + } + + // 玩家投降 + async surrender(gameSessionId: string, playerId: string): Promise { + const session = await GameSession.findById(gameSessionId) + + if (!session) { + throw new Error('游戏会话不存在') + } + + if (session.gameState.phase === 'FINISHED') { + throw new Error('游戏已结束') + } + + const winnerId = session.players.find(p => p !== playerId)! + + // 更新游戏状态 + session.gameState.phase = 'FINISHED' + session.gameState.winner = winnerId + session.gameState.endedAt = new Date() + session.gameState.surrendered = true + + await session.save() + + // 更新玩家统计 + await this.updatePlayerStats(winnerId, playerId) + + // 通知游戏结束 + await websocketService.notifyGameUpdate(session.roomCode, { + type: 'GAME_ENDED', + winner: winnerId, + reason: 'SURRENDER', + gameState: session.gameState + }) + } + + // 更新玩家统计 + private async updatePlayerStats(winnerId: string, loserId: string): Promise { + const User = require('../models/User').User + + // 更新获胜者统计 + await User.updateOne( + { _id: winnerId }, + { + $inc: { + 'stats.totalGames': 1, + 'stats.wins': 1 + } + } + ) + + // 更新失败者统计 + await User.updateOne( + { _id: loserId }, + { + $inc: { + 'stats.totalGames': 1 + } + } + ) + + // 重新计算胜率 + const users = await User.find({ _id: { $in: [winnerId, loserId] } }) + for (const user of users) { + user.stats.winRate = user.stats.totalGames > 0 + ? (user.stats.wins / user.stats.totalGames * 100).toFixed(2) + : 0 + await user.save() + } + } +} +``` + +## 3. WebSocket服务设计 + +### 3.1 WebSocket连接管理 + +```typescript +// websocket/connection.ts +import { WebSocket } from 'ws' +import { EventEmitter } from 'events' +import { redisClient } from '../config/redis' + +export class WebSocketManager extends EventEmitter { + private connections = new Map() + private userConnections = new Map() // userId -> connectionIds[] + private roomConnections = new Map() // roomCode -> connectionIds[] + + // 添加连接 + addConnection(connectionId: string, ws: WebSocket, userId: string): void { + const connection = new WebSocketConnection(connectionId, ws, userId) + + this.connections.set(connectionId, connection) + + // 建立用户映射 + if (!this.userConnections.has(userId)) { + this.userConnections.set(userId, []) + } + this.userConnections.get(userId)!.push(connectionId) + + // 设置连接事件处理 + connection.on('message', (message) => { + this.handleMessage(connectionId, message) + }) + + connection.on('close', () => { + this.removeConnection(connectionId) + }) + + connection.on('error', (error) => { + console.error(`WebSocket连接错误 ${connectionId}:`, error) + this.removeConnection(connectionId) + }) + + console.log(`WebSocket连接已建立: ${connectionId} (用户: ${userId})`) + } + + // 移除连接 + removeConnection(connectionId: string): void { + const connection = this.connections.get(connectionId) + if (!connection) return + + const userId = connection.userId + + // 移除连接映射 + this.connections.delete(connectionId) + + // 移除用户映射 + const userConns = this.userConnections.get(userId) + if (userConns) { + const index = userConns.indexOf(connectionId) + if (index > -1) { + userConns.splice(index, 1) + } + if (userConns.length === 0) { + this.userConnections.delete(userId) + } + } + + // 移除房间映射 + for (const [roomCode, connections] of this.roomConnections.entries()) { + const index = connections.indexOf(connectionId) + if (index > -1) { + connections.splice(index, 1) + if (connections.length === 0) { + this.roomConnections.delete(roomCode) + } + + // 通知房间内其他用户 + this.notifyRoomUpdate(roomCode, { + type: 'PLAYER_DISCONNECTED', + playerId: userId + }) + } + } + + console.log(`WebSocket连接已关闭: ${connectionId} (用户: ${userId})`) + } +} \ No newline at end of file diff --git a/02_详细设计文档/实时通信模块详设.md b/02_详细设计文档/实时通信模块详设.md new file mode 100644 index 0000000..a7e45b6 --- /dev/null +++ b/02_详细设计文档/实时通信模块详设.md @@ -0,0 +1,280 @@ +# 实时通信模块详设文档 + +> **文档版本**: v1.0 +> **撰写人**: 通信架构师 +> **创建日期**: 2024年9月11日 + +## 1. WebSocket通信协议 + +### 1.1 协议选型 + +- **WebSocket**: 基于TCP的全双工通信协议,提供持久化连接,是实现游戏实时对战、状态同步的最佳选择。 +- **WSS (WebSocket Secure)**: 在生产环境强制使用WSS协议,确保所有通信数据都经过TLS加密,保障数据安全。 + +### 1.2 消息格式 + +所有客户端与服务端之间的WebSocket消息都采用统一的JSON格式进行封装,便于解析和扩展。 + +```json +{ + "type": "MESSAGE_TYPE_ENUM", + "payload": { + "key1": "value1", + "key2": "value2" + }, + "timestamp": "2024-09-11T10:00:00.000Z", + "client_message_id": "optional-client-uuid-for-ack" +} +``` + +- `type`: 消息类型,用于路由到不同的处理逻辑。 +- `payload`: 消息体,包含具体业务数据。 +- `timestamp`: 消息发送的UTC时间戳。 +- `client_message_id`: (可选) 客户端生成的消息ID,用于实现消息确认(ACK)机制。 + +### 1.3 心跳机制 + +为维持连接活性并检测死链,采用双向心跳机制: + +- **客户端**: 每隔25秒向服务端发送一个`PING`消息。 +- **服务端**: + - 收到`PING`消息后,立即回复一个`PONG`消息。 + - 如果在60秒内未收到任何客户端消息(包括`PING`),则认为连接已断开,主动关闭该WebSocket连接。 + +```typescript +// 客户端PING消息 +{ + "type": "PING", + "payload": {}, + "timestamp": "..." +} + +// 服务端PONG消息 +{ + "type": "PONG", + "payload": {}, + "timestamp": "..." +} +``` + +## 2. 消息类型与数据结构 + +### 2.1 消息类型枚举 (`GameMessageType`) + +```typescript +// 客户端 -> 服务端 (C2S) +export enum ClientToServerMessageType { + // --- 系统级 --- + PING = 'PING', // 心跳检测 + AUTHENTICATE = 'AUTHENTICATE', // 身份认证 + + // --- 房间管理 --- + CREATE_ROOM = 'CREATE_ROOM', // 创建房间 + JOIN_ROOM = 'JOIN_ROOM', // 加入房间 + LEAVE_ROOM = 'LEAVE_ROOM', // 离开房间 + GET_ROOM_LIST = 'GET_ROOM_LIST', // 获取房间列表 + PLAYER_READY = 'PLAYER_READY', // 玩家准备 + + // --- 游戏逻辑 --- + PLACE_PLANES = 'PLACE_PLANES', // 放置飞机 + EXECUTE_ATTACK = 'EXECUTE_ATTACK', // 执行攻击 + SURRENDER = 'SURRENDER' // 投降 +} + +// 服务端 -> 客户端 (S2C) +export enum ServerToClientMessageType { + // --- 系统级 --- + PONG = 'PONG', // 心跳响应 + AUTHENTICATED = 'AUTHENTICATED', // 认证成功 + ERROR = 'ERROR', // 错误通知 + + // --- 房间与游戏状态同步 --- + ROOM_LIST_UPDATE = 'ROOM_LIST_UPDATE', // 房间列表更新 + ROOM_STATE_UPDATE = 'ROOM_STATE_UPDATE',// 房间状态更新 + GAME_STATE_UPDATE = 'GAME_STATE_UPDATE',// 游戏状态更新 + + // --- 游戏事件通知 --- + GAME_STARTED = 'GAME_STARTED', // 游戏开始 + PLACEMENT_PHASE_START = 'PLACEMENT_PHASE_START', // 放置阶段开始 + BATTLE_PHASE_START = 'BATTLE_PHASE_START', // 对战阶段开始 + TURN_CHANGE = 'TURN_CHANGE', // 回合变更 + ATTACK_RESULT = 'ATTACK_RESULT', // 攻击结果 + GAME_OVER = 'GAME_OVER', // 游戏结束 + PLAYER_RECONNECTED = 'PLAYER_RECONNECTED',// 玩家重连 + PLAYER_DISCONNECTED = 'PLAYER_DISCONNECTED'// 玩家断线 +} +``` + +### 2.2 核心消息体 (`Payload`) 详解 + +#### `AUTHENTICATE` (C2S) +- **描述**: 客户端连接后发送的第一条消息,用于身份认证。 +- **Payload**: + ```typescript + interface AuthenticatePayload { + token: string; // 从HTTP登录接口获取的JWT + } + ``` + +#### `AUTHENTICATED` (S2C) +- **描述**: 服务端认证成功后返回的消息。 +- **Payload**: + ```typescript + interface AuthenticatedPayload { + userId: string; + nickname: string; + // ... 其他用户信息 + } + ``` + +#### `CREATE_ROOM` (C2S) +- **描述**: 客户端请求创建新房间。 +- **Payload**: + ```typescript + interface CreateRoomPayload { + roomName: string; + isPublic: boolean; + password?: string; // 如果是私密房间 + } + ``` + +#### `ROOM_STATE_UPDATE` (S2C) +- **描述**: 当房间状态(如玩家加入/退出/准备)发生变化时,服务端向房间内所有客户端广播。 +- **Payload**: `RoomState` 对象 (详见后端设计文档) + +#### `PLACE_PLANES` (C2S) +- **描述**: 玩家在布置阶段提交飞机布局。 +- **Payload**: + ```typescript + interface PlacePlanesPayload { + planes: { + center: { x: number, y: number }; + direction: 'up' | 'down' | 'left' | 'right'; + }[]; + } + ``` + +#### `EXECUTE_ATTACK` (C2S) +- **描述**: 玩家在对战阶段执行攻击。 +- **Payload**: + ```typescript + interface ExecuteAttackPayload { + position: { x: number, y: number }; + } + ``` + +#### `GAME_STATE_UPDATE` (S2C) +- **描述**: 游戏核心状态发生变化时,服务端向游戏内玩家广播。 +- **Payload**: `GameState` 对象 (详见游戏核心逻辑设计文档),但会根据接收玩家进行数据裁剪(如隐藏对手未被攻击的飞机位置)。 + +#### `ATTACK_RESULT` (S2C) +- **描述**: 服务端通知攻击结果。 +- **Payload**: + ```typescript + interface AttackResultPayload { + attackerId: string; + position: { x: number, y: number }; + result: 'miss' | 'hit' | 'destroy'; + targetPlaneId?: string; // 如果击中 + isGameOver: boolean; + } + ``` + +#### `ERROR` (S2C) +- **描述**: 服务端向客户端发送错误信息。 +- **Payload**: + ```typescript + interface ErrorPayload { + code: number; // 错误码 + message: string; // 错误信息 + requestType?: string; // 导致错误的请求类型 + } + ``` + +## 3. 断线重连机制 + +### 3.1 核心流程 + +1. **客户端检测断线**: + - WebSocket `onclose` 事件被触发。 + - 或,发送`PING`后超过10秒未收到`PONG`。 + +2. **自动重连**: + - 客户端立即尝试重新建立WebSocket连接。 + - 采用**指数退避算法**进行重连尝试,例如:首次延迟1秒,然后2秒, 4秒, 8秒... 直到成功或达到最大重连次数(5次)。 + +3. **重连后认证**: + - 新连接建立后,客户端必须立即发送`AUTHENTICATE`消息,并携带之前的JWT。 + +4. **服务端处理重连**: + - 服务端通过JWT识别出这是同个玩家的重连请求。 + - 服务端查找该玩家当前是否处于某个游戏会话中。 + - 如果在游戏中,服务端将最新的`GameState`发送给该玩家,并向房间内所有玩家广播`PLAYER_RECONNECTED`事件。 + +5. **状态同步**: + - 客户端收到完整的`GameState`后,恢复游戏界面,确保与服务器状态一致。 + +### 3.2 服务端实现要点 + +- **会话持久化**: 玩家的`userId`和其`connectionId`的映射关系需要存储在Redis中,并设置合理的过期时间(如5分钟),以便在断线期间保留会话信息。 +- **游戏状态恢复**: `GameStateMachine`实例必须在玩家断线时保留在内存中,直到游戏结束或超时。当玩家重连时,可以从该实例获取最新状态。 + +```typescript +// Redis中存储的重连会话信息 +// Key: "reconnect:session:{userId}" +// Value (HASH): +// connectionId: "previous-connection-id" +// gameId: "current-game-id" +// roomCode: "current-room-code" +// expireAt: "timestamp" +``` + +## 4. 多节点部署与消息同步 + +### 4.1 挑战 + +当后端WebSocket服务部署在多个节点上时,同一房间的两个玩家可能连接到不同的服务器实例。一个玩家发送的消息需要被广播给连接在另一台服务器上的对手。 + +### 4.2 解决方案:Redis Pub/Sub + +使用Redis的发布/订阅(Pub/Sub)机制作为消息总线,实现跨节点通信。 + +1. **消息流**: + - 客户端A向服务器S1发送消息(如`EXECUTE_ATTACK`)。 + - S1处理消息,更新游戏状态。 + - S1将需要广播的消息(如`ATTACK_RESULT`, `GAME_STATE_UPDATE`)发布到一个特定的Redis频道,例如`game-room:{roomCode}`。 + - 所有后端服务器实例(S1, S2, ...)都订阅了相关的频道。 + - S2接收到`game-room:{roomCode}`频道的消息。 + +2. **消息投递**: + - S2查找连接在本机且属于`roomCode`房间的客户端(即客户端B)。 + - S2将消息通过WebSocket连接发送给客户端B。 + +### 4.3 `socket.io` 与 `socket.io-redis-adapter` + +为简化实现,推荐使用`socket.io`库及其官方Redis适配器`socket.io-redis-adapter`。 + +- **`socket.io`**: 提供了房间(room)、广播(broadcast)、命名空间(namespace)等高级抽象,并内置了心跳和自动重连机制。 +- **`socket.io-redis-adapter`**: 自动处理了上述的Redis Pub/Sub逻辑。只需简单配置,即可实现多节点间的无缝消息广播。 + +#### 示例配置 +```typescript +import { Server } from 'socket.io'; +import { createAdapter } from '@socket.io/redis-adapter'; +import { createClient } from 'redis'; + +const io = new Server(); + +const pubClient = createClient({ url: 'redis://localhost:6379' }); +const subClient = pubClient.duplicate(); + +Promise.all([pubClient.connect(), subClient.connect()]).then(() => { + io.adapter(createAdapter(pubClient, subClient)); + io.listen(3000); +}); + +// 使用方法 +io.to('some-room-code').emit('event_name', { data: '...' }); +``` + +此方案将所有跨节点通信的复杂性都交由`socket.io-redis-adapter`处理,使业务代码可以专注于游戏逻辑,无需关心底层的消息路由。 \ No newline at end of file diff --git a/02_详细设计文档/数据库设计详设.md b/02_详细设计文档/数据库设计详设.md new file mode 100644 index 0000000..b0d9e1f --- /dev/null +++ b/02_详细设计文档/数据库设计详设.md @@ -0,0 +1,210 @@ +# 数据库设计详设文档 + +> **文档版本**: v1.0 +> **撰写人**: 数据库架构师 +> **创建日期**: 2024年9月11日 + +## 1. 技术选型 + +- **主数据库**: **MongoDB 6.0+** + - **原因**: 采用面向文档的存储模型,非常适合存储游戏会话、用户配置等半结构化数据。其灵活的Schema设计能快速迭代,内建的复制和分片功能为高可用和高扩展性提供了有力支持。 +- **缓存/消息队列**: **Redis 7.0+** + - **原因**: 基于内存的高性能键值存储,用于缓存热点数据(如用户信息、排行榜)、管理WebSocket会话、实现分布式锁及作为多节点部署时的消息总线(Pub/Sub)。 +- **ORM/ODM**: **Mongoose 7.x** + - **原因**: 为MongoDB提供强大的对象数据建模(ODM)能力,支持Schema定义、数据校验、中间件、查询构建等功能,能显著提升开发效率和代码健壮性。 + +## 2. MongoDB 数据模型设计 + +### 2.1 `users` 集合 + +存储用户信息。 + +- **Schema 定义**: + ```typescript + import { Schema, model } from 'mongoose'; + + const userSchema = new Schema({ + _id: { type: String, required: true }, // 使用微信的 openid 作为主键 + nickname: { type: String, required: true }, + avatarUrl: { type: String, required: true }, + + stats: { + gamesPlayed: { type: Number, default: 0 }, + gamesWon: { type: Number, default: 0 }, + winRate: { type: Number, default: 0.0 }, + totalShots: { type: Number, default: 0 }, + totalHits: { type: Number, default: 0 }, + accuracy: { type: Number, default: 0.0 }, + eloRating: { type: Number, default: 1200 } // Elo积分系统 + }, + + lastLoginAt: { type: Date, default: Date.now }, + createdAt: { type: Date, default: Date.now, immutable: true }, + updatedAt: { type: Date, default: Date.now } + }, { + timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' } + }); + + // Mongoose 中间件,用于在胜率和准确率变化时自动更新 + userSchema.pre('save', function(next) { + if (this.isModified('stats.gamesPlayed') || this.isModified('stats.gamesWon')) { + this.stats.winRate = this.stats.gamesPlayed > 0 ? (this.stats.gamesWon / this.stats.gamesPlayed) * 100 : 0; + } + if (this.isModified('stats.totalShots') || this.isModified('stats.totalHits')) { + this.stats.accuracy = this.stats.totalShots > 0 ? (this.stats.totalHits / this.stats.totalShots) * 100 : 0; + } + next(); + }); + + export const UserModel = model('User', userSchema); + ``` + +- **索引 (Indexes)**: + - `_id`: (主键) 唯一索引,用于快速查找用户。 + - `{ "stats.eloRating": -1 }`: 降序索引,用于实现排行榜。 + +### 2.2 `game_sessions` 集合 + +存储完整的游戏对局信息,用于复盘、数据分析和问题排查。 + +- **Schema 定义**: + ```typescript + import { Schema, model } from 'mongoose'; + + const positionSchema = new Schema({ x: Number, y: Number }, { _id: false }); + const planeSchema = new Schema({ + center: positionSchema, + direction: String, + positions: [positionSchema] + }, { _id: false }); + + const playerStateSchema = new Schema({ + userId: { type: String, ref: 'User', required: true }, + board: { + planes: [planeSchema], + attackHistory: [{ + position: positionSchema, + result: String, // 'miss', 'hit', 'destroy' + timestamp: Date + }] + }, + stats: { + shots: Number, + hits: Number, + planesDestroyed: Number + } + }, { _id: false }); + + const gameSessionSchema = new Schema({ + _id: { type: String, required: true }, // 游戏会话ID + roomCode: { type: String, required: true, index: true }, + status: { type: String, required: true, enum: ['placing', 'battling', 'finished'], default: 'placing' }, + + players: [{ type: String, ref: 'User' }], + playerStates: [playerStateSchema], + + winnerId: { type: String, ref: 'User' }, + winReason: { type: String, enum: ['ALL_PLANES_DESTROYED', 'SURRENDER', 'TIMEOUT'] }, + + gameEvents: [{ + type: String, + playerId: String, + data: Schema.Types.Mixed, + timestamp: Date + }], + + startedAt: { type: Date, default: Date.now }, + finishedAt: { type: Date } + }); + + export const GameSessionModel = model('GameSession', gameSessionSchema); + ``` + +- **索引 (Indexes)**: + - `_id`: (主键) 唯一索引。 + - `{ roomCode: 1 }`: 用于通过房间码快速查找游戏。 + - `{ "players": 1 }`: 多键索引,用于查询某玩家参与的所有对局。 + - `{ status: 1, startedAt: -1 }`: 复合索引,用于查找特定状态的游戏并按时间排序。 + +## 3. Redis 数据结构设计 + +Redis 用于存储高频访问、易失性或需要原子操作的数据。 + +### 3.1 用户会话 (User Session) + +- **用途**: 存储用户登录状态和WebSocket连接信息。 +- **数据结构**: `HASH` +- **Key**: `session:{userId}` +- **Value**: + - `token`: `string` (JWT) + - `connectionId`: `string` (当前WebSocket连接ID) + - `status`: `'online' | 'offline' | 'in-game'` + - `gameId`: `string` (如果`status`为`in-game`) +- **TTL**: 24小时 (每次访问刷新) + +### 3.2 游戏房间 (Game Rooms) + +- **用途**: 管理游戏房间列表和房间内玩家状态。 +- **数据结构**: `HASH` +- **Key**: `room:{roomCode}` +- **Value**: + - `name`: `string` (房间名) + - `ownerId`: `string` (房主用户ID) + - `status`: `'waiting' | 'full' | 'in-game'` + - `player1Id`: `string` + - `player2Id`: `string` + - `player1Ready`: `'0' | '1'` + - `player2Ready`: `'0' | '1'` +- **TTL**: 2小时 (从最后一次活动开始计算) + +### 3.3 游戏状态 (Live Game State) + +- **用途**: 缓存进行中游戏的核心状态,减少对MongoDB的读写压力。 +- **数据结构**: `STRING` (存储序列化后的`GameState`对象) +- **Key**: `game:state:{gameId}` +- **Value**: `JSON.stringify(GameState)` +- **TTL**: 1小时 (游戏结束后删除) + +### 3.4 排行榜 (Leaderboard) + +- **用途**: 实时更新和查询玩家排名。 +- **数据结构**: `SORTED SET` (ZSET) +- **Key**: `leaderboard:elo` +- **Value**: + - `member`: `userId` + - `score`: `eloRating` (整数) + +- **操作**: + - **更新排名**: `ZADD leaderboard:elo ` + - **查询Top 100**: `ZREVRANGE leaderboard:elo 0 99 WITHSCORES` + - **查询玩家排名**: `ZREVRANK leaderboard:elo ` + +### 3.5 分布式锁 (Distributed Lock) + +- **用途**: 在关键操作(如匹配玩家、创建游戏)中防止并发冲突。 +- **数据结构**: `STRING` +- **Key**: `lock:{resource_name}:{resource_id}` (e.g., `lock:room:join:{roomCode}`) +- **Value**: `unique_lock_id` (e.g., a UUID) +- **操作**: 使用`SET key value NX PX milliseconds`命令实现原子性的加锁操作。 + - `NX`: 只在键不存在时设置。 + - `PX`: 设置过期时间(毫秒),防止死锁。 + +## 4. 数据一致性策略 + +- **写操作**: + 1. **关键操作** (如`EXECUTE_ATTACK`): + - 开启**分布式锁**。 + - 更新Redis中的**实时游戏状态** (`game:state:{gameId}`)。 + - 将操作事件**异步写入**一个队列(如Redis Stream或RabbitMQ)。 + - **释放锁**。 + - 立即向客户端返回成功响应。 + 2. 一个独立的**后台Worker**消费队列中的事件,批量将游戏会话数据持久化到MongoDB (`game_sessions` 集合)。 + +- **读操作**: + - **进行中的游戏**: 优先从**Redis**读取实时状态。 + - **历史游戏/玩家统计**: 从**MongoDB**读取。 + +- **优势**: + - **低延迟**: 游戏核心逻辑的读写都在内存中完成,响应迅速。 + - **高吞吐**: 将对DB的写操作异步化和批量化,减轻数据库压力。 + - **数据最终一致性**: 即使后台Worker暂时失败,数据也保留在队列中,保证最终会持久化到MongoDB。 \ No newline at end of file diff --git a/02_详细设计文档/游戏核心逻辑详设.md b/02_详细设计文档/游戏核心逻辑详设.md new file mode 100644 index 0000000..6f605ff --- /dev/null +++ b/02_详细设计文档/游戏核心逻辑详设.md @@ -0,0 +1,908 @@ +# 游戏核心逻辑详设文档 + +> **文档版本**: v1.0 +> **撰写人**: 游戏逻辑架构师 +> **创建日期**: 2024年9月11日 + +## 1. 游戏逻辑总览 + +### 1.1 核心游戏机制 + +基于经典"打飞机"游戏规则,实现回合制战略对战: + +```typescript +// 游戏核心参数 +const GAME_CONFIG = { + BOARD_SIZE: 10, // 10x10棋盘 + PLANE_COUNT: 3, // 每位玩家3架飞机 + PLANE_SIZE: 11, // 每架飞机占11个格子 + TURN_TIME_LIMIT: 30, // 每回合30秒限时 + GAME_TIME_LIMIT: 1800 // 游戏总时长30分钟 +} + +// 游戏阶段枚举 +enum GamePhase { + WAITING = 'waiting', // 等待玩家 + PLACING = 'placing', // 飞机布置阶段 + BATTLING = 'battling', // 对战阶段 + FINISHED = 'finished' // 游戏结束 +} + +// 攻击结果类型 +enum AttackResult { + MISS = 'miss', // 未命中 + HIT = 'hit', // 命中 + DESTROY = 'destroy' // 击毁飞机 +} +``` + +### 1.2 飞机几何模型 + +```typescript +// 飞机形状定义 +interface PlaneShape { + id: string + center: Position // 飞机头部位置(中心点) + direction: Direction // 飞机朝向 + positions: Position[] // 飞机占据的所有位置 + parts: { + head: Position // 头部(1个格子) + wings: Position[] // 翅膀(5个格子) + body: Position[] // 机身(2个格子) + tail: Position[] // 尾翼(3个格子) + } +} + +// 方向枚举 +enum Direction { + UP = 'up', + DOWN = 'down', + LEFT = 'left', + RIGHT = 'right' +} + +// 位置坐标 +interface Position { + x: number // 行坐标 (0-9) + y: number // 列坐标 (0-9) +} + +// 飞机几何生成器 +export class PlaneGeometry { + static generatePlane(center: Position, direction: Direction): PlaneShape { + const plane: PlaneShape = { + id: generateId(), + center, + direction, + positions: [], + parts: { + head: center, + wings: [], + body: [], + tail: [] + } + } + + switch (direction) { + case Direction.UP: + plane.parts = { + head: center, + wings: [ + { x: center.x + 1, y: center.y - 2 }, + { x: center.x + 1, y: center.y - 1 }, + { x: center.x + 1, y: center.y }, + { x: center.x + 1, y: center.y + 1 }, + { x: center.x + 1, y: center.y + 2 } + ], + body: [ + { x: center.x + 2, y: center.y }, + { x: center.x + 3, y: center.y } + ], + tail: [ + { x: center.x + 4, y: center.y - 1 }, + { x: center.x + 4, y: center.y }, + { x: center.x + 4, y: center.y + 1 } + ] + } + break + + case Direction.DOWN: + plane.parts = { + head: center, + wings: [ + { x: center.x - 1, y: center.y - 2 }, + { x: center.x - 1, y: center.y - 1 }, + { x: center.x - 1, y: center.y }, + { x: center.x - 1, y: center.y + 1 }, + { x: center.x - 1, y: center.y + 2 } + ], + body: [ + { x: center.x - 2, y: center.y }, + { x: center.x - 3, y: center.y } + ], + tail: [ + { x: center.x - 4, y: center.y - 1 }, + { x: center.x - 4, y: center.y }, + { x: center.x - 4, y: center.y + 1 } + ] + } + break + + case Direction.LEFT: + plane.parts = { + head: center, + wings: [ + { x: center.x - 2, y: center.y + 1 }, + { x: center.x - 1, y: center.y + 1 }, + { x: center.x, y: center.y + 1 }, + { x: center.x + 1, y: center.y + 1 }, + { x: center.x + 2, y: center.y + 1 } + ], + body: [ + { x: center.x, y: center.y + 2 }, + { x: center.x, y: center.y + 3 } + ], + tail: [ + { x: center.x - 1, y: center.y + 4 }, + { x: center.x, y: center.y + 4 }, + { x: center.x + 1, y: center.y + 4 } + ] + } + break + + case Direction.RIGHT: + plane.parts = { + head: center, + wings: [ + { x: center.x - 2, y: center.y - 1 }, + { x: center.x - 1, y: center.y - 1 }, + { x: center.x, y: center.y - 1 }, + { x: center.x + 1, y: center.y - 1 }, + { x: center.x + 2, y: center.y - 1 } + ], + body: [ + { x: center.x, y: center.y - 2 }, + { x: center.x, y: center.y - 3 } + ], + tail: [ + { x: center.x - 1, y: center.y - 4 }, + { x: center.x, y: center.y - 4 }, + { x: center.x + 1, y: center.y - 4 } + ] + } + break + } + + // 合并所有位置 + plane.positions = [ + plane.parts.head, + ...plane.parts.wings, + ...plane.parts.body, + ...plane.parts.tail + ] + + return plane + } + + // 验证飞机位置是否合法 + static validatePlanePosition(plane: PlaneShape, boardSize: number = 10): boolean { + return plane.positions.every(pos => + pos.x >= 0 && pos.x < boardSize && + pos.y >= 0 && pos.y < boardSize + ) + } + + // 检查两架飞机是否重叠 + static checkPlanesOverlap(plane1: PlaneShape, plane2: PlaneShape): boolean { + return plane1.positions.some(pos1 => + plane2.positions.some(pos2 => + pos1.x === pos2.x && pos1.y === pos2.y + ) + ) + } +} +``` + +## 2. 棋盘状态管理 + +### 2.1 棋盘数据结构 + +```typescript +// 单元格状态 +enum CellState { + EMPTY = 'empty', // 空格 + PLANE_PART = 'plane_part', // 飞机部件 + ATTACKED_MISS = 'attacked_miss', // 攻击未命中 + ATTACKED_HIT = 'attacked_hit' // 攻击命中 +} + +// 棋盘单元格 +interface BoardCell { + position: Position + state: CellState + planeId?: string // 所属飞机ID + partType?: 'head' | 'wing' | 'body' | 'tail' // 部件类型 + isDestroyed?: boolean // 是否已被击毁 + attackedAt?: Date // 攻击时间 +} + +// 游戏棋盘 +interface GameBoard { + size: number // 棋盘大小 (10x10) + cells: BoardCell[][] // 二维单元格数组 + planes: PlaneShape[] // 放置的飞机 + attackHistory: AttackRecord[] // 攻击历史 + remainingPlanes: number // 剩余飞机数量 +} + +// 攻击记录 +interface AttackRecord { + position: Position + result: AttackResult + timestamp: Date + targetPlaneId?: string +} +``` + +### 2.2 棋盘操作类 + +```typescript +export class BoardManager { + // 创建空棋盘 + static createEmptyBoard(size: number = 10): GameBoard { + const cells: BoardCell[][] = [] + + for (let x = 0; x < size; x++) { + cells[x] = [] + for (let y = 0; y < size; y++) { + cells[x][y] = { + position: { x, y }, + state: CellState.EMPTY + } + } + } + + return { + size, + cells, + planes: [], + attackHistory: [], + remainingPlanes: 0 + } + } + + // 在棋盘上放置飞机 + static placePlane(board: GameBoard, plane: PlaneShape): boolean { + // 验证飞机位置合法性 + if (!PlaneGeometry.validatePlanePosition(plane, board.size)) { + return false + } + + // 检查是否与现有飞机重叠 + for (const existingPlane of board.planes) { + if (PlaneGeometry.checkPlanesOverlap(plane, existingPlane)) { + return false + } + } + + // 在棋盘上标记飞机位置 + plane.positions.forEach(pos => { + const cell = board.cells[pos.x][pos.y] + cell.state = CellState.PLANE_PART + cell.planeId = plane.id + + // 标记部件类型 + if (pos.x === plane.parts.head.x && pos.y === plane.parts.head.y) { + cell.partType = 'head' + } else if (plane.parts.wings.some(w => w.x === pos.x && w.y === pos.y)) { + cell.partType = 'wing' + } else if (plane.parts.body.some(b => b.x === pos.x && b.y === pos.y)) { + cell.partType = 'body' + } else { + cell.partType = 'tail' + } + }) + + // 添加飞机到棋盘 + board.planes.push(plane) + board.remainingPlanes++ + + return true + } + + // 批量放置飞机 + static placePlanes(board: GameBoard, planes: PlaneShape[]): boolean { + if (planes.length !== 3) { + throw new Error('必须放置3架飞机') + } + + // 创建临时棋盘进行验证 + const tempBoard = this.createEmptyBoard(board.size) + + // 逐个放置验证 + for (const plane of planes) { + if (!this.placePlane(tempBoard, plane)) { + return false + } + } + + // 验证通过,应用到实际棋盘 + board.cells = tempBoard.cells + board.planes = tempBoard.planes + board.remainingPlanes = tempBoard.remainingPlanes + + return true + } + + // 执行攻击 + static executeAttack(board: GameBoard, position: Position): AttackResult { + const cell = board.cells[position.x][position.y] + + // 检查是否已经攻击过该位置 + if (cell.state === CellState.ATTACKED_MISS || cell.state === CellState.ATTACKED_HIT) { + throw new Error('该位置已被攻击过') + } + + let result: AttackResult + let targetPlaneId: string | undefined + + if (cell.state === CellState.PLANE_PART) { + // 命中飞机 + cell.state = CellState.ATTACKED_HIT + cell.isDestroyed = true + targetPlaneId = cell.planeId + + // 检查飞机是否完全被击毁 + const plane = board.planes.find(p => p.id === targetPlaneId)! + const allPartsDestroyed = plane.positions.every(pos => { + const targetCell = board.cells[pos.x][pos.y] + return targetCell.isDestroyed + }) + + if (allPartsDestroyed) { + result = AttackResult.DESTROY + board.remainingPlanes-- + + // 标记整架飞机为已击毁 + plane.positions.forEach(pos => { + board.cells[pos.x][pos.y].isDestroyed = true + }) + } else { + result = AttackResult.HIT + } + } else { + // 未命中 + cell.state = CellState.ATTACKED_MISS + result = AttackResult.MISS + } + + // 记录攻击历史 + const attackRecord: AttackRecord = { + position, + result, + timestamp: new Date(), + targetPlaneId + } + board.attackHistory.push(attackRecord) + + return result + } + + // 检查游戏是否结束 + static isGameOver(board: GameBoard): boolean { + return board.remainingPlanes === 0 + } + + // 获取对手视图的棋盘(隐藏未被攻击的飞机位置) + static getOpponentView(board: GameBoard): GameBoard { + const opponentBoard = JSON.parse(JSON.stringify(board)) as GameBoard + + // 隐藏未被攻击的飞机位置 + for (let x = 0; x < board.size; x++) { + for (let y = 0; y < board.size; y++) { + const cell = opponentBoard.cells[x][y] + if (cell.state === CellState.PLANE_PART && !cell.isDestroyed) { + cell.state = CellState.EMPTY + delete cell.planeId + delete cell.partType + } + } + } + + return opponentBoard + } + + // 获取棋盘统计信息 + static getBoardStats(board: GameBoard): BoardStats { + const totalCells = board.size * board.size + const attackedCells = board.attackHistory.length + const hitCells = board.attackHistory.filter(a => a.result !== AttackResult.MISS).length + const accuracy = attackedCells > 0 ? (hitCells / attackedCells * 100) : 0 + + return { + totalCells, + attackedCells, + hitCells, + accuracy: Math.round(accuracy * 100) / 100, + remainingPlanes: board.remainingPlanes, + destroyedPlanes: 3 - board.remainingPlanes + } + } +} + +interface BoardStats { + totalCells: number + attackedCells: number + hitCells: number + accuracy: number + remainingPlanes: number + destroyedPlanes: number +} +``` + +## 3. 游戏状态机 + +### 3.1 游戏状态管理 + +```typescript +// 游戏状态 +interface GameState { + gameId: string + roomCode: string + phase: GamePhase + players: GamePlayer[] + currentPlayer: string + boards: { [playerId: string]: GameBoard } + gameConfig: GameConfig + timeState: TimeState + events: GameEvent[] + result?: GameResult +} + +// 游戏玩家 +interface GamePlayer { + id: string + nickname: string + avatar?: string + isReady: boolean + isOnline: boolean + stats: PlayerGameStats +} + +// 玩家游戏内统计 +interface PlayerGameStats { + attacksCount: number + hitsCount: number + planesDestroyed: number + accuracy: number + timeUsed: number +} + +// 时间状态 +interface TimeState { + gameStartTime?: Date + gameEndTime?: Date + currentTurnStartTime?: Date + turnTimeLimit: number + totalTimeLimit: number + turnTimeRemaining: number + gameTimeRemaining: number +} + +// 游戏事件 +interface GameEvent { + id: string + type: GameEventType + playerId: string + timestamp: Date + data: any +} + +enum GameEventType { + GAME_STARTED = 'game_started', + PLANE_PLACED = 'plane_placed', + PLACEMENT_COMPLETED = 'placement_completed', + TURN_STARTED = 'turn_started', + ATTACK_EXECUTED = 'attack_executed', + PLANE_DESTROYED = 'plane_destroyed', + TURN_TIMEOUT = 'turn_timeout', + PLAYER_DISCONNECTED = 'player_disconnected', + PLAYER_RECONNECTED = 'player_reconnected', + GAME_ENDED = 'game_ended' +} +``` + +### 3.2 游戏状态机实现 + +```typescript +export class GameStateMachine { + private state: GameState + private timers: Map = new Map() + + constructor(gameState: GameState) { + this.state = gameState + } + + // 开始游戏 + startGame(): void { + if (this.state.phase !== GamePhase.WAITING) { + throw new Error('游戏状态错误,无法开始游戏') + } + + this.state.phase = GamePhase.PLACING + this.state.timeState.gameStartTime = new Date() + + // 设置游戏总时长定时器 + this.setGameTimeLimit() + + this.addEvent({ + type: GameEventType.GAME_STARTED, + playerId: '', + data: { startTime: this.state.timeState.gameStartTime } + }) + } + + // 玩家放置飞机 + placePlanes(playerId: string, planes: PlaneShape[]): void { + if (this.state.phase !== GamePhase.PLACING) { + throw new Error('当前不是飞机放置阶段') + } + + const player = this.getPlayer(playerId) + if (player.isReady) { + throw new Error('玩家已经完成飞机放置') + } + + // 放置飞机到棋盘 + const board = this.state.boards[playerId] + const success = BoardManager.placePlanes(board, planes) + + if (!success) { + throw new Error('飞机放置失败') + } + + // 标记玩家已准备 + player.isReady = true + + this.addEvent({ + type: GameEventType.PLACEMENT_COMPLETED, + playerId, + data: { planes: planes.length } + }) + + // 检查是否所有玩家都已准备 + if (this.allPlayersReady()) { + this.startBattle() + } + } + + // 开始对战阶段 + private startBattle(): void { + this.state.phase = GamePhase.BATTLING + + // 随机选择先手玩家 + const firstPlayer = this.state.players[Math.floor(Math.random() * this.state.players.length)] + this.state.currentPlayer = firstPlayer.id + + this.startTurn() + } + + // 开始新回合 + private startTurn(): void { + this.state.timeState.currentTurnStartTime = new Date() + this.state.timeState.turnTimeRemaining = this.state.timeState.turnTimeLimit + + // 设置回合时间限制 + this.setTurnTimeLimit() + + this.addEvent({ + type: GameEventType.TURN_STARTED, + playerId: this.state.currentPlayer, + data: { timeLimit: this.state.timeState.turnTimeLimit } + }) + } + + // 执行攻击 + executeAttack(playerId: string, position: Position): AttackResult { + if (this.state.phase !== GamePhase.BATTLING) { + throw new Error('当前不是对战阶段') + } + + if (this.state.currentPlayer !== playerId) { + throw new Error('不是你的回合') + } + + // 获取对手棋盘 + const opponentId = this.getOpponent(playerId).id + const opponentBoard = this.state.boards[opponentId] + + // 执行攻击 + const result = BoardManager.executeAttack(opponentBoard, position) + + // 更新玩家统计 + const player = this.getPlayer(playerId) + player.stats.attacksCount++ + if (result !== AttackResult.MISS) { + player.stats.hitsCount++ + player.stats.accuracy = (player.stats.hitsCount / player.stats.attacksCount) * 100 + } + if (result === AttackResult.DESTROY) { + player.stats.planesDestroyed++ + } + + this.addEvent({ + type: GameEventType.ATTACK_EXECUTED, + playerId, + data: { position, result, opponentId } + }) + + if (result === AttackResult.DESTROY) { + this.addEvent({ + type: GameEventType.PLANE_DESTROYED, + playerId: opponentId, + data: { attackerId: playerId, position } + }) + } + + // 检查游戏是否结束 + if (BoardManager.isGameOver(opponentBoard)) { + this.endGame(playerId) + } else { + // 切换回合 + this.switchTurn() + } + + return result + } + + // 切换回合 + private switchTurn(): void { + this.clearTurnTimer() + + const currentPlayerIndex = this.state.players.findIndex(p => p.id === this.state.currentPlayer) + const nextPlayerIndex = (currentPlayerIndex + 1) % this.state.players.length + this.state.currentPlayer = this.state.players[nextPlayerIndex].id + + this.startTurn() + } + + // 回合超时处理 + private handleTurnTimeout(): void { + this.addEvent({ + type: GameEventType.TURN_TIMEOUT, + playerId: this.state.currentPlayer, + data: { timeUsed: this.state.timeState.turnTimeLimit } + }) + + // 自动跳过回合 + this.switchTurn() + } + + // 结束游戏 + private endGame(winnerId: string): void { + this.state.phase = GamePhase.FINISHED + this.state.timeState.gameEndTime = new Date() + + const winner = this.getPlayer(winnerId) + const loser = this.getOpponent(winnerId) + + this.state.result = { + winnerId, + loserId: loser.id, + winReason: 'ALL_PLANES_DESTROYED', + gameStats: { + duration: this.getGameDuration(), + totalMoves: this.state.events.filter(e => e.type === GameEventType.ATTACK_EXECUTED).length, + winnerStats: winner.stats, + loserStats: loser.stats + } + } + + // 清除所有定时器 + this.clearAllTimers() + + this.addEvent({ + type: GameEventType.GAME_ENDED, + playerId: winnerId, + data: this.state.result + }) + } + + // 玩家断线处理 + handlePlayerDisconnection(playerId: string): void { + const player = this.getPlayer(playerId) + player.isOnline = false + + this.addEvent({ + type: GameEventType.PLAYER_DISCONNECTED, + playerId, + data: { timestamp: new Date() } + }) + + // 如果是对战阶段且是当前玩家断线,暂停计时 + if (this.state.phase === GamePhase.BATTLING && this.state.currentPlayer === playerId) { + this.pauseTurnTimer() + } + } + + // 玩家重连处理 + handlePlayerReconnection(playerId: string): void { + const player = this.getPlayer(playerId) + player.isOnline = true + + this.addEvent({ + type: GameEventType.PLAYER_RECONNECTED, + playerId, + data: { timestamp: new Date() } + }) + + // 如果是对战阶段且是当前玩家重连,恢复计时 + if (this.state.phase === GamePhase.BATTLING && this.state.currentPlayer === playerId) { + this.resumeTurnTimer() + } + } + + // 定时器管理方法 + private setGameTimeLimit(): void { + const timer = setTimeout(() => { + this.endGameByTimeout() + }, this.state.timeState.totalTimeLimit * 1000) + + this.timers.set('gameTime', timer) + } + + private setTurnTimeLimit(): void { + const timer = setTimeout(() => { + this.handleTurnTimeout() + }, this.state.timeState.turnTimeLimit * 1000) + + this.timers.set('turnTime', timer) + } + + private clearTurnTimer(): void { + const timer = this.timers.get('turnTime') + if (timer) { + clearTimeout(timer) + this.timers.delete('turnTime') + } + } + + private clearAllTimers(): void { + this.timers.forEach(timer => clearTimeout(timer)) + this.timers.clear() + } + + private pauseTurnTimer(): void { + // 实现回合计时器暂停逻辑 + this.clearTurnTimer() + } + + private resumeTurnTimer(): void { + // 实现回合计时器恢复逻辑 + this.setTurnTimeLimit() + } + + // 辅助方法 + private getPlayer(playerId: string): GamePlayer { + const player = this.state.players.find(p => p.id === playerId) + if (!player) { + throw new Error('玩家不存在') + } + return player + } + + private getOpponent(playerId: string): GamePlayer { + const opponent = this.state.players.find(p => p.id !== playerId) + if (!opponent) { + throw new Error('对手不存在') + } + return opponent + } + + private allPlayersReady(): boolean { + return this.state.players.every(p => p.isReady) + } + + private addEvent(event: Omit): void { + const gameEvent: GameEvent = { + id: generateId(), + timestamp: new Date(), + ...event + } + this.state.events.push(gameEvent) + } + + private getGameDuration(): number { + if (!this.state.timeState.gameStartTime || !this.state.timeState.gameEndTime) { + return 0 + } + return this.state.timeState.gameEndTime.getTime() - this.state.timeState.gameStartTime.getTime() + } + + private endGameByTimeout(): void { + // 根据当前分数决定胜负 + const player1 = this.state.players[0] + const player2 = this.state.players[1] + + const player1Score = player1.stats.planesDestroyed + const player2Score = player2.stats.planesDestroyed + + let winnerId: string + if (player1Score > player2Score) { + winnerId = player1.id + } else if (player2Score > player1Score) { + winnerId = player2.id + } else { + // 平局,根据命中率决定 + winnerId = player1.stats.accuracy >= player2.stats.accuracy ? player1.id : player2.id + } + + this.endGame(winnerId) + } + + // 获取当前游戏状态 + getState(): GameState { + return { ...this.state } + } + + // 获取玩家视图的游戏状态 + getPlayerView(playerId: string): any { + const state = this.getState() + + // 隐藏对手棋盘上未被攻击的飞机 + const opponentId = this.getOpponent(playerId).id + state.boards[opponentId] = BoardManager.getOpponentView(state.boards[opponentId]) + + return state + } +} +``` + +## 4. 游戏规则验证 + +### 4.1 输入验证器 + +```typescript +export class GameValidator { + // 验证飞机放置是否合法 + static validatePlanesPlacement(planes: PlaneShape[]): ValidationResult { + const errors: string[] = [] + + // 检查飞机数量 + if (planes.length !== 3) { + errors.push('必须放置3架飞机') + } + + // 检查每架飞机的合法性 + planes.forEach((plane, index) => { + // 检查飞机形状是否正确 + if (plane.positions.length !== 11) { + errors.push(`第${index + 1}架飞机形状不正确`) + } + + // 检查飞机是否在棋盘范围内 + if (!PlaneGeometry.validatePlanePosition(plane)) { + errors.push(`第${index + 1}架飞机位置超出棋盘范围`) + } + }) + + // 检查飞机之间是否重叠 + for (let i = 0; i < planes.length; i++) { + for (let j = i + 1; j < planes.length; j++) { + if (PlaneGeometry.checkPlanesOverlap(planes[i], planes[j])) { + errors.push(`第${i + 1}架和第${j + 1}架飞机位置重叠`) + } + } + } + + return { + isValid: errors.length === 0, + errors + } + } +} + +interface ValidationResult { + isValid: boolean + errors: string[] +} \ No newline at end of file diff --git a/02_详细设计文档/部署运维详设.md b/02_详细设计文档/部署运维详设.md new file mode 100644 index 0000000..a2527cb --- /dev/null +++ b/02_详细设计文档/部署运维详设.md @@ -0,0 +1,256 @@ +# 部署运维详设文档 + +> **文档版本**: v1.0 +> **撰写人**: DevOps工程师 +> **创建日期**: 2024年9月11日 + +## 1. 架构总览 + +我们将采用云原生技术栈,以容器化为核心,利用Kubernetes进行服务编排,实现高可用、可扩展、易于维护的部署架构。 + +- **云服务提供商**: 推荐使用腾讯云、阿里云等主流云厂商,以获得稳定的基础设施和丰富的云产品支持。 +- **容器化**: Docker +- **容器编排**: Kubernetes (K8s) +- **CI/CD**: GitHub Actions +- **监控与告警**: Prometheus + Grafana + Alertmanager +- **日志管理**: ELK Stack (Elasticsearch, Logstash, Kibana) 或 Loki + +## 2. 容器化 (Docker) + +### 2.1 后端服务 Dockerfile + +一个优化的、多阶段构建的`Dockerfile`示例: + +```dockerfile +# ---- Base Stage ---- +FROM node:18-alpine AS base +WORKDIR /app +COPY package*.json ./ + +# ---- Dependencies Stage ---- +FROM base AS dependencies +RUN npm install --frozen-lockfile + +# ---- Build Stage ---- +FROM dependencies AS build +COPY . . +RUN npm run build + +# ---- Production Stage ---- +FROM node:18-alpine AS production +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=dependencies /app/node_modules ./node_modules +COPY package*.json ./ + +EXPOSE 8080 +CMD ["node", "dist/main.js"] +``` + +- **多阶段构建**: 减小最终镜像体积,只包含生产运行所需的依赖。 +- **使用`alpine`镜像**: 基于轻量级的Alpine Linux,进一步减小镜像大小。 +- **缓存优化**: 将`package.json`的复制和`npm install`分层,利用Docker的层缓存机制,只有在依赖变更时才重新安装。 + +### 2.2 镜像仓库 + +- **推荐**: 使用云厂商提供的容器镜像服务(如腾讯云TCR、阿里云ACR)。 +- **CI/CD集成**: GitHub Actions将在构建成功后,自动将Docker镜像推送到指定的镜像仓库,并打上版本标签(如`git commit hash`)。 + +## 3. Kubernetes (K8s) 部署 + +### 3.1 部署物清单 (Manifests) + +我们将使用YAML文件来定义所有K8s资源。 + +#### a) `deployment.yaml` (后端服务) +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: game-server-deployment +spec: + replicas: 3 # 初始副本数为3,可根据负载自动伸缩 + selector: + matchLabels: + app: game-server + template: + metadata: + labels: + app: game-server + spec: + containers: + - name: game-server + image: your-registry/game-server:latest # 镜像地址 + ports: + - containerPort: 8080 + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "500m" + memory: "1Gi" + envFrom: + - configMapRef: + name: game-server-config + - secretRef: + name: game-server-secrets + livenessProbe: # 存活探针 + httpGet: + path: /api/v1/healthz + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: # 就绪探针 + httpGet: + path: /api/v1/healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 +``` + +#### b) `service.yaml` (服务暴露) +```yaml +apiVersion: v1 +kind: Service +metadata: + name: game-server-service +spec: + type: LoadBalancer # 使用云厂商的LB暴露服务 + selector: + app: game-server + ports: + - protocol: TCP + port: 80 # LB监听80端口 + targetPort: 8080 +``` + +#### c) `hpa.yaml` (水平Pod自动伸缩) +```yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: game-server-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: game-server-deployment + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 75 # CPU使用率超过75%时扩容 +``` + +### 3.2 配置与密钥管理 + +- **ConfigMap**: 用于存储非敏感配置,如数据库地址、Redis地址、日志级别等。 +- **Secret**: 用于存储敏感信息,如数据库密码、JWT密钥等。必须进行Base64编码。 + +## 4. CI/CD (GitHub Actions) + +### 4.1 工作流 (`.github/workflows/deploy.yml`) + +```yaml +name: Deploy to Production +on: + push: + branches: + - main + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm install --frozen-lockfile + + - name: Run tests + run: npm test + + - name: Log in to Docker Registry + uses: docker/login-action@v2 + with: + registry: your-registry.com + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: your-registry.com/game-server:${{ github.sha }} + + - name: Set up Kubeconfig + uses: azure/k8s-set-context@v3 + with: + method: kubeconfig + kubeconfig: ${{ secrets.KUBECONFIG }} + + - name: Deploy to Kubernetes + uses: azure/k8s-deploy@v4 + with: + action: 'deploy' + manifests: | + k8s/deployment.yaml + k8s/service.yaml + images: | + your-registry.com/game-server:${{ github.sha }} +``` + +- **触发条件**: 当代码推送到`main`分支时自动触发。 +- **流程**: 拉取代码 -> 安装依赖 -> 运行测试 -> 登录镜像仓库 -> 构建并推送镜像 -> 部署到K8s。 +- **密钥管理**: 所有敏感信息(如密码、Kubeconfig)都存储在GitHub Secrets中。 + +## 5. 监控与告警 + +- **Prometheus**: + - 通过`kube-prometheus-stack`部署在K8s集群中。 + - 自动发现并抓取K8s Pod和Node的指标。 + - 后端服务需要暴露一个`/metrics`端点,提供自定义业务指标(如在线玩家数、活跃游戏数、API响应延迟等)。 +- **Grafana**: + - 提供可视化的监控仪表盘(Dashboard)。 + - 预置Dashboard用于监控集群资源、Node.js应用性能等。 + - 自定义Dashboard展示核心业务指标。 +- **Alertmanager**: + - 根据Prometheus中预设的告警规则(如CPU使用率过高、服务Pod重启频繁、API错误率上升),通过邮件、钉钉、企业微信等方式发送告警通知。 + +## 6. 日志管理 + +- **方案**: **Loki + Promtail** + - **Promtail**: 作为日志代理,部署在每个K8s节点上,负责收集容器日志并发送给Loki。 + - **Loki**: 轻量级的日志聚合系统,对日志进行索引和存储。 +- **集成**: 在Grafana中配置Loki作为数据源,可以直接在Grafana中查询和分析日志,与监控指标在同一平台展示,方便问题排查。 + +## 7. 部署策略 (蓝绿部署) + +为实现零停机更新,采用蓝绿部署策略。 + +1. **当前版本 (Blue)**: `game-server-deployment-blue` 正在运行,并通过`game-server-service`对外提供服务。 +2. **部署新版本 (Green)**: + - 创建一个新的Deployment `game-server-deployment-green`,包含新版本的代码。 + - 等待`green`环境的所有Pod都进入`Ready`状态。 +3. **流量切换**: + - 修改`game-server-service`的`selector`,将其指向`green`环境的Pod (`app: game-server-green`)。 + - K8s会自动将流量无缝切换到新版本。 +4. **观察期**: + - 观察新版本运行是否稳定,监控核心指标是否正常。 +5. **下线旧版本**: + - 如果一切正常,删除`game-server-deployment-blue`。 + - 如果出现问题,可以快速将Service的`selector`切回`blue`环境,实现秒级回滚。 + +此流程可通过CI/CD脚本自动化。 \ No newline at end of file