Initial commit

This commit is contained in:
wfz
2026-07-26 14:41:36 +08:00
commit f741a0c8e8
90 changed files with 10774 additions and 0 deletions

597
docs/architecture.md Normal file
View File

@@ -0,0 +1,597 @@
# 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` | 键盘/鼠标输入 |