Files
zp2/docs/architecture.md
2026-07-26 14:41:36 +08:00

598 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ZP2 全局架构文档
## 1. 项目概述
ZP2 是一款多人在线俯视角 3D 游戏,玩家在 32x32 网格地图上移动、实时同步位置。本项目是 ZP1 的精简版,仅保留房间管理、地图加载、玩家移动和基础 3D 显示功能。
### 技术栈
| 层级 | 技术 | 版本 |
|------|------|------|
| 后端 | Java | 17 |
| 构建 | Maven | - |
| 通信 | Java-WebSocket | 1.5.4 |
| JSON | Gson + Jackson | 2.10.1 / 2.15.2 |
| 前端 | JavaScript (ES Module) | - |
| 渲染 | Three.js | ^0.170.0 |
| 构建 | Vite | ^6.0.0 |
---
## 2. 系统架构总览
```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Browser) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ main.js │───→│ engine.js│───→│ scene.js │ │ client.js │ │
│ │ (App) │ │ (Loop/ │ │ (Three.js│ │(WebSocket)│ │
│ │ │ │ Predict) │ │ Render) │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └─────┬────┘ │
│ │ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ lobby.js │ │ input.js │ │ grid.js │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │ │
└─────────────────────────────────────────────────────────┼────────┘
│ WebSocket
│ JSON
┌─────────────────────────────────────────────────────────┼────────┐
│ Backend (Java) │ │
│ ┌──────────────────────────────────────────────────────┤ │
│ │ GameWebSocketServer (8080) │←───────┘
│ │ onMessage 消息路由 → 房间CRUD / 玩家输入 / 状态广播 │
│ └──────┬───────────┬──────────────┬───────────────────┘
│ │ │ │
│ ┌──────┴───┐ ┌────┴─────┐ ┌────┴──────────┐
│ │RoomMgr │ │GameService│ │ GameLoop │
│ │(房间生命周期)│ │(游戏会话) │ │(30TPS固定帧) │
│ └──────────┘ └────┬─────┘ └───────────────┘
│ │
│ ┌───────────────────┴────────────────────────────┐
│ │ ECSWorld │
│ │ 实体(Entities) + 组件(组件) + 系统(Systems) │
│ │ ┌──────────────────────────────────────────┐ │
│ │ │ PlayerInputSystem: 移动 + 碰撞 + 朝向 │ │
│ │ │ StateSyncSystem: 构建状态快照 → 广播 │ │
│ │ └──────────────────────────────────────────┘ │
│ └────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────┘
```
---
## 3. 后端架构
### 3.1 启动流程
```
GameServerMain.main()
├── 解析端口参数 (默认 8080)
├── 创建 GameWebSocketServer
├── server.start() 启动 WebSocket 监听
└── 注册 shutdown hook优雅关闭
```
### 3.2 网络层 — GameWebSocketServer
核心职责:管理 WebSocket 连接、路由消息、广播状态。
**连接管理:**
- `connectionToPlayer`: WebSocket → playerId 映射
- `playerToConnection`: playerId → WebSocket 映射
**消息路由onMessage**
| 消息类型 | 处理方法 | 说明 |
|----------|----------|------|
| `create_room` | `handleCreateRoom` | 创建房间,生成 playerId 和 roomId |
| `join_room` | `handleJoinRoom` | 加入房间,广播新状态 |
| `leave_room` | `handleLeaveRoomByConn` | 离开房间,清理游戏会话 |
| `room_list` | `handleRoomList` | 返回可用房间列表 |
| `ready` | `handleReady` | 切换玩家准备状态 |
| `start_game` | `handleStartGame` | 房主开始游戏(需全员准备) |
| `player_input` | `handlePlayerInput` | 转发玩家输入到 GameService |
**定时任务:**
- 每 2 秒广播房间列表给未加入房间的玩家
### 3.3 房间管理 — RoomManager
使用 `ConcurrentHashMap` 保证线程安全:
- `rooms`: roomId → Room 映射
- `playerToRoom`: playerId → roomId 映射
- `getAvailableRooms()`: 返回未开始游戏的房间
**Room 实体:**
- `id`: 房间唯一标识UUID 前8位
- `hostId`: 房主 playerId
- `players`: LinkedHashMap 保持加入顺序最多4人
- `gameStarted`: 游戏是否已开始
### 3.4 游戏服务 — GameService
**startGame(room) 流程:**
```
1. 加载地图文件 (maps/d540209a.json)
2. 创建 ECSWorld
3. 注册 PlayerInputSystem
4. 为每个玩家创建 PlayerEntity分配到出生点
5. 创建并启动 GameLoop
6. 返回每个玩家的初始化数据mapData + players
```
**processPlayerInput**
- 查找玩家对应的 ECS 实体
- 更新 PlayerInput 组件dx, dy, aimX, aimY, seq
- 使用 `synchronized(world.lock)` 保证线程安全
### 3.5 游戏循环 — GameLoop
固定时间步长循环30 TPS
```
ScheduledExecutorService
└── scheduleAtFixedRate(tick, 0, 33ms)
├── synchronized(world.lock) { world.update(dt) }
└── broadcaster.broadcast(roomId, world)
```
- 每 33ms1000/30执行一次 tick
- tick 内更新所有 ECS 系统
- 更新完成后广播游戏状态
### 3.6 ECS 架构
#### ECSWorld
核心容器,管理所有实体和组件:
| 组件映射 | 类型 | 说明 |
|----------|------|------|
| `entities` | `LinkedHashSet<Integer>` | 所有活跃实体 |
| `players` | `LinkedHashSet<Integer>` | 玩家实体集合 |
| `positions` | `Map<Integer, Position>` | 位置组件 |
| `healths` | `Map<Integer, Health>` | 生命值组件 |
| `collisions` | `Map<Integer, Collision>` | 碰撞组件 |
| `renderInfos` | `Map<Integer, RenderInfo>` | 渲染组件 |
| `playerInputs` | `Map<Integer, PlayerInput>` | 输入组件 |
| `systems` | `List<System>` | 系统列表 |
| `playerIdToEntity` | `Map<String, Integer>` | 玩家ID到实体ID映射 |
**createPlayerEntity(playerId, name, x, y)**
```
1. createEntity() 获取新实体ID
2. 添加 Position(x, y)
3. 添加 Health(100)
4. 添加 Collision(PLAYER_SIZE=0.8)
5. 添加 RenderInfo(PLAYER)
6. 添加 PlayerInput()
7. 加入 players 集合
8. 建立 playerId → entityId 映射
```
#### System 接口
```java
public interface System {
void update(float dt, ECSWorld world);
}
```
#### PlayerInputSystem
每帧处理所有玩家输入:
```
对于每个玩家实体:
1. 获取 PlayerInput / Position / Health
2. 跳过死亡或缺少组件的实体
3. 计算移动newX = x + dx * PLAYER_SPEED * TICK_INTERVAL
4. 逐轴碰撞检测isWalkable(newX, y, PLAYER_SIZE)
5. 限制在地图边界 [0.5, GRID_SIZE-0.5]
6. 计算朝向atan2(aimX - x, aimY - y)
```
#### StateSyncSystem
构建游戏状态快照:
```json
{
"players": [
{
"id": "player-uuid",
"x": 12.5,
"y": 8.3,
"angle": 1.57,
"health": 100,
"lastProcessedSeq": 42
}
],
"gameTime": 12.5
}
```
### 3.7 地图系统
**GameMap** 从 JSON 文件加载:
```json
{
"width": 32,
"height": 32,
"walls": [{"x": 5, "y": 10, "type": "static"}],
"playerSpawns": [{"x": 2, "y": 2}],
"zombieSpawns": [{"x": 15, "y": 15}]
}
```
- `cells[y][x]`: 0=空地, 1=墙, 2=玩家出生点, 3=僵尸出生点
- `isWalkable(wx, wy, size)`: 检查实体四个角是否均可通行
- `isWall(gx, gy)`: 判断网格是否为墙或越界
**MapStorage**: 基于文件的地图持久化save/load/list/delete
---
## 4. 前端架构
### 4.1 应用入口 — main.js
```
App 构造函数
├── 创建 lobbyEl 和 gameCanvasEl
├── 实例化 LobbyUI
└── _setupLobby() 注册回调
```
**视图切换:**
- 默认显示 lobby隐藏 canvas
- `game_started` 消息后:隐藏 lobby显示 canvas
**连接管理_ensureConnection**
```
if (!engine):
创建 GameEngine → connect(WS_URL) → 注册网络处理器
else if (!connected):
重连
```
### 4.2 游戏引擎 — engine.js
#### 核心数据结构
```javascript
this.players = Map<playerId, {
id, name, x, y, angle, health, color, isLocal
}>
this.pendingInputs = [] // 待确认输入队列
```
#### 游戏循环(固定时间步长)
```
requestAnimationFrame(_loop)
delta = now - lastTick
accumulator += delta
while (accumulator >= TICK_INTERVAL):
_tick()
accumulator -= TICK_INTERVAL
updateCamera(localPlayer)
scene.render()
```
#### 客户端预测_applyLocalPrediction
```
1. 读取输入状态 (dx, dy)
2. 计算新位置: newX = x + dx * SPEED * dt
3. 逐轴碰撞检测
4. 限制在地图范围内 [0.5, 31.5]
5. 计算朝向: atan2(aimX - x, aimY - y)
6. 更新场景中的玩家模型
```
#### 服务器校正_reconcileLocalPlayer
```
1. 用服务器权威状态覆盖本地位置
2. 移除 seq <= lastProcessedSeq 的输入(已确认)
3. 对剩余未确认输入重新执行预测
4. 更新场景
```
这是经典的 **客户端预测 + 服务器校正** 模式:
- 客户端立即响应输入(零延迟手感)
- 服务器每帧广播权威状态
- 客户端用 seq 号匹配已处理输入,重新模拟未确认输入
### 4.3 3D 渲染 — scene.js
**场景设置:**
- 透视相机 (FOV=45°, 偏移量 (0, 25, 18))
- 环境光 + 方向光(带阴影)+ 点光源
- 窗口大小自适应
**地图渲染buildMap**
- 地板: 32x32 Plane (深灰色)
- 墙壁: BoxGeometry(1, 1.5, 1) (蓝灰色)
- 出生点: BoxGeometry(1, 0.1, 1) (绿色半透明)
**玩家模型createPlayerModel**
```
Group
├── 身体: Cylinder(半径 0.4, 高 0.8)
├── 头部: Sphere(半径 0.2, 肤色)
└── 武器: Box(0.08, 0.08, 0.5, 深色)
```
**鼠标射线投射getMouseGroundPos**
```
鼠标屏幕坐标 → NDC → Raycaster → 与 Y=0 平面相交 → 地面世界坐标
```
### 4.4 网络客户端 — client.js
WebSocket 封装,提供:
- 事件注册:`on(type, handler)`
- 消息发送:`send(type, data)`
- 便捷方法:`createRoom`, `joinRoom`, `sendInput`
消息格式:
```json
{
"type": "player_input",
"data": {"dx": 1, "dy": 0, "aimX": 10, "aimY": 5, "seq": 42}
}
```
### 4.5 输入管理 — input.js
**键盘输入:**
- WASD / 方向键 → 移动向量
- 对角线归一化×0.7071
**鼠标输入:**
- 位置追踪 (clientX, clientY)
- 左右键状态
**buildInputState**
```javascript
{
seq: sequenceNumber++, // 递增序列号
dx: movement.dx, // 水平输入 [-1, 1]
dy: movement.dy, // 垂直输入 [-1, 1]
aimX: mouseGroundPos.x, // 瞄准点 X
aimY: mouseGroundPos.y // 瞄准点 Y
}
```
### 4.6 大厅 UI — lobby.js
两个视图状态:
**房间列表视图:**
- 玩家名称输入框
- 创建房间 / 刷新按钮
- 房间列表(点击 Join 加入)
**房间视图:**
- 玩家列表(显示准备状态)
- Ready 按钮
- Start Game 按钮(仅房主可见)
- Leave 按钮
### 4.7 网格碰撞 — grid.js
**Grid 类:**
- `parseMap(mapData)`: 解析二维数组
- `isWall(gx, gy)`: 越界返回 true检查 cells == 1
- `worldToGrid(wx, wy)`: 世界坐标 → 网格坐标
- `isWalkable(wx, wy, size)`: 检查四个角是否可通行
**generateDefaultMap**
- 边界墙(最外层)
- 预设墙体段14段
- 4个角落出生点周围自动清理墙体
---
## 5. 网络协议
### 消息格式
所有消息使用统一的 `{type, data}` 信封格式:
```json
{"type": "xxx", "data": {...}}
```
### 消息类型汇总
| 方向 | type | 说明 |
|------|------|------|
| C→S | `create_room` | 创建房间 `{playerName}` |
| C→S | `join_room` | 加入房间 `{roomId, playerName}` |
| C→S | `leave_room` | 离开房间 |
| C→S | `room_list` | 请求房间列表 |
| C→S | `ready` | 切换准备状态 |
| C→S | `start_game` | 开始游戏(仅房主) |
| C→S | `player_input` | 玩家输入 `{dx, dy, aimX, aimY, seq}` |
| S→C | `room_list` | 房间列表 `{rooms[]}` |
| S→C | `room_state` | 房间状态 `{roomId, hostId, isHost, playerId, players[]}` |
| S→C | `game_started` | 游戏开始 `{playerId, mapData, players[]}` |
| S→C | `game_state` | 游戏状态 `{players[{id, x, y, angle, health, lastProcessedSeq}], gameTime}` |
| S→C | `error` | 错误 `{message}` |
### 通信时序
```
Client A Server Client B
| | |
|── create_room ──────→| |
|←── room_state ──────| |
| |←── join_room ────────|
|←── room_state ──────|─── room_state ──────→|
| | |
|── ready ───────────→| |
|←── room_state ──────|─── room_state ──────→|
| | |
|── start_game ──────→| |
|←── game_started ────|─── game_started ────→|
| | |
|═══ player_input ═══→| |
| |═══ game_state ══════→|
|←══ game_state ══════| |
```
---
## 6. 数据流
### 6.1 玩家移动完整数据流
```
InputManager (键盘/鼠标)
buildInputState() → {seq, dx, dy, aimX, aimY}
├──→ _applyLocalPrediction() [客户端预测]
│ ├── 新位置计算
│ ├── 碰撞检测
│ └── 更新 Three.js 模型
└──→ network.sendInput() ──→ WebSocket → Server
GameService.processPlayerInput()
ECSWorld.update(dt)
PlayerInputSystem.update()
├── 更新 Position 组件
└── 更新朝向角度
StateSyncSystem.buildGameState()
broadcast → WebSocket → Client
_processServerState()
├── 远程玩家: 直接更新
└── 本地玩家: _reconcileLocalPlayer()
├── 应用服务器状态
├── 移除已确认输入
└── 重放未确认输入
```
### 6.2 生命周期
```
[创建房间]
Client → create_room → Server → RoomManager.addRoom() → Room 实例
[加入房间]
Client → join_room → Server → RoomManager.joinRoom() → 广播 room_state
[开始游戏]
Client → start_game → Server → GameService.startGame()
├── 加载地图
├── 创建 ECSWorld + PlayerInputSystem
├── 创建 PlayerEntity分配出生点
├── 启动 GameLoop (30 TPS)
└── 广播 game_started含 mapData + players
[游戏进行中]
GameLoop.tick() → ECSWorld.update() → PlayerInputSystem → 广播 game_state
[玩家断开]
onClose → RoomManager.leaveRoom() → 清理游戏会话(如房间为空)
```
---
## 7. 关键设计模式
### 7.1 ECS (Entity-Component-System)
- **Entity**: 整数 ID仅作为标识符
- **Component**: 纯数据Position, Health, PlayerInput 等)
- **System**: 处理逻辑PlayerInputSystem, StateSyncSystem
优势:解耦数据与逻辑,便于扩展新实体类型。
### 7.2 客户端预测 + 服务器校正
- 客户端立即响应输入,不等待服务器
- 服务器每帧广播权威状态
- 客户端用序列号匹配已处理输入,重新模拟未确认输入
- 结果:零延迟手感 + 服务器权威防作弊
### 7.3 固定时间步长
- 客户端和服务器均使用 30 TPS
- 渲染帧率与逻辑帧率解耦(渲染用 requestAnimationFrame
- 保证确定性模拟
### 7.4 线程安全
- ECSWorld 使用 `synchronized(lock)` 保护
- RoomManager 使用 `ConcurrentHashMap`
- GameLoop 使用单线程 ScheduledExecutorService
---
## 8. 文件索引
### 后端
| 文件 | 职责 |
|------|------|
| `GameServerMain.java` | 服务器入口,启动 WebSocket |
| `model/Constants.java` | 游戏常量 + 消息类型 |
| `model/Room.java` | 房间实体 |
| `model/PlayerInfo.java` | 玩家信息 |
| `model/GameMap.java` | 地图加载 + 碰撞检测 |
| `model/MapData.java` | 地图序列化 DTO |
| `model/Wall.java` | 墙体抽象基类 |
| `model/StaticWall.java` | 不可破坏墙体 |
| `ecs/ECSWorld.java` | ECS 世界容器 |
| `ecs/System.java` | 系统接口 |
| `ecs/components/Position.java` | 位置组件 |
| `ecs/components/PlayerInput.java` | 输入组件 |
| `ecs/components/Health.java` | 生命值组件 |
| `ecs/components/Collision.java` | 碰撞组件 |
| `ecs/components/RenderInfo.java` | 渲染信息组件 |
| `server/GameWebSocketServer.java` | WebSocket 服务器 |
| `server/RoomManager.java` | 房间生命周期管理 |
| `server/GameService.java` | 游戏会话管理 |
| `server/GameLoop.java` | 30TPS 固定帧循环 |
| `server/MessageUtils.java` | JSON 安全提取工具 |
| `server/MapStorage.java` | 文件地图持久化 |
| `systems/PlayerInputSystem.java` | 移动 + 碰撞处理 |
| `systems/StateSyncSystem.java` | 状态快照构建 |
### 前端
| 文件 | 职责 |
|------|------|
| `index.html` | HTML 外壳 |
| `src/main.js` | 应用入口,大厅/游戏切换 |
| `src/style.css` | 全局样式 |
| `src/game/engine.js` | 游戏引擎,预测 + 校正 |
| `src/game/scene.js` | Three.js 3D 渲染 |
| `src/network/client.js` | WebSocket 客户端 |
| `src/ui/lobby.js` | 大厅 UI |
| `src/utils/constants.js` | 游戏常量 |
| `src/utils/grid.js` | 网格碰撞 + 默认地图 |
| `src/utils/input.js` | 键盘/鼠标输入 |