feat: 添加完整的项目设计文档,替换旧的原型需求文档

This commit is contained in:
史悦
2025-09-11 15:13:38 +08:00
parent e922bacfb8
commit b8d081bd8a
10 changed files with 4755 additions and 155 deletions

View File

@@ -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. **回合结束:** 对手攻击完成后,系统自动切换回“我的回合”页面。

View File

@@ -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<boolean>
// 房间状态同步
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<UserInfo> {
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<string, any>()
private pending = new Map<string, Promise<any>>()
async request<T>(
url: string,
options?: RequestOptions
): Promise<T> {
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<T>(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 <ErrorFallback onRetry={() => 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提供数据支撑
- 完整的状态管理和错误处理
本方案将为您的打飞机小程序提供坚实的技术基础,确保项目按时高质量交付。
---
**文档状态**: ✅ 完成
**技术评审**: 待进行
**下一步**: 开始技术选型实施

View File

@@ -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 <JWT>`头进行身份验证。
- **统一响应格式**: 所有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",
// ...
}
}
```
- **后续通信**: 详见《实时通信模块详设文档》。

View File

@@ -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<HTMLButtonElement> {
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<HTMLInputElement> {
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`)。

File diff suppressed because it is too large Load Diff

View File

@@ -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<FastifyInstance> => {
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<WxSessionResponse> {
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<void> {
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<void> {
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<void> {
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<Room> {
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<Room> {
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<void> {
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<Room[]> {
const rooms = await Room.find({
status: 'waiting',
currentPlayers: { $lt: 2 }
}).populate('host', 'nickname avatar').lean()
return rooms
}
// 获取房间详情
async getRoomDetails(roomCode: string): Promise<Room | null> {
// 先从缓存获取
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<void> {
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<void> {
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<GameSession> {
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<void> {
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<AttackResult> {
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<any> {
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<void> {
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<void> {
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<string, WebSocketConnection>()
private userConnections = new Map<string, string[]>() // userId -> connectionIds[]
private roomConnections = new Map<string, string[]>() // 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})`)
}
}

View File

@@ -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`处理,使业务代码可以专注于游戏逻辑,无需关心底层的消息路由。

View File

@@ -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 <eloRating> <userId>`
- **查询Top 100**: `ZREVRANGE leaderboard:elo 0 99 WITHSCORES`
- **查询玩家排名**: `ZREVRANK leaderboard:elo <userId>`
### 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。

View File

@@ -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<string, NodeJS.Timeout> = 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<GameEvent, 'id' | 'timestamp'>): 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[]
}

View File

@@ -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脚本自动化。