Initial commit
This commit is contained in:
597
docs/architecture.md
Normal file
597
docs/architecture.md
Normal 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)
|
||||
```
|
||||
|
||||
- 每 33ms(1000/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` | 键盘/鼠标输入 |
|
||||
256
docs/migration-plan-zh.md
Normal file
256
docs/migration-plan-zh.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# ZP1 最小化迁移计划
|
||||
|
||||
## 目标
|
||||
将 zp1 重写为 zp2,仅保留:房间管理、地图加载、玩家移动、前端必要显示。技术栈不变(Java + Vite/Three.js),保留 ECS 架构和客户端预测。
|
||||
|
||||
## 范围
|
||||
- **保留**:房间系统、地图加载/渲染、玩家移动(WASD)、客户端预测、WebSocket 通信
|
||||
- **移除**:僵尸、武器/子弹、炮塔、火焰区域、掉落物、爆炸/特效、HUD、设置界面、地图编辑器、模板系统、对象池
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:后端骨架
|
||||
|
||||
### 1.1 项目结构
|
||||
```
|
||||
zp2/backend/
|
||||
├── pom.xml
|
||||
└── src/main/java/com/zombie/game/
|
||||
├── GameServerMain.java # 入口,仅启动 WebSocket 服务器
|
||||
├── model/
|
||||
│ ├── Constants.java # 精简为:GRID_SIZE, TICK_RATE, PLAYER_SIZE, PLAYER_SPEED, MSG_*
|
||||
│ ├── Room.java # 保持原样
|
||||
│ ├── PlayerInfo.java # 保持原样
|
||||
│ ├── GameMap.java # 精简:移除流向场,仅保留 isWall/isWalkable
|
||||
│ ├── MapData.java # 保持,用于地图反序列化
|
||||
│ └── StaticWall.java # 仅保留静态墙
|
||||
├── server/
|
||||
│ ├── GameWebSocketServer.java # 精简:房间操作 + 玩家输入 + 状态广播
|
||||
│ ├── RoomManager.java # 保持原样
|
||||
│ ├── GameService.java # 精简:仅注册 PlayerInputSystem + StateSyncSystem
|
||||
│ ├── GameLoop.java # 保持原样(30 TPS)
|
||||
│ ├── MessageUtils.java # 保持原样
|
||||
│ └── MapStorage.java # 保留,用于加载地图文件
|
||||
├── ecs/
|
||||
│ ├── ECSWorld.java # 精简:仅管理 players,移除僵尸/子弹/掉落物/炮塔/火焰区域
|
||||
│ ├── System.java # 保持接口
|
||||
│ └── components/
|
||||
│ ├── Position.java # 保持
|
||||
│ ├── PlayerInput.java # 精简:移除手雷相关字段
|
||||
│ ├── Health.java # 精简
|
||||
│ ├── Collision.java # 保持
|
||||
│ └── RenderInfo.java # 精简
|
||||
└── systems/
|
||||
├── PlayerInputSystem.java # 保持:移动 + 碰撞 + 朝向
|
||||
└── StateSyncSystem.java # 精简:仅广播玩家位置
|
||||
```
|
||||
|
||||
### 1.2 关键变更
|
||||
|
||||
**Constants.java**
|
||||
- 保留:`GRID_SIZE=32`, `TICK_RATE=30`, `PLAYER_SIZE=0.8f`, `PLAYER_SPEED=5.0f`, `MSG_*` 消息类型常量
|
||||
- 移除:`ZOMBIE_SIZE`, `LOOT_*`, 武器常量
|
||||
|
||||
**GameMap.java**
|
||||
- 保留:`loadFromJson()`, `isWall()`, `isWalkable()`, `getCells()`, `getSpawnPoints()`
|
||||
- 移除:整个 `FlowField`,`updateFlowField()`, `getFlowDirection()`, `addNutWall()`, 坚果墙逻辑
|
||||
- 仅保留 `StaticWall`(移除 `NutWall`, `TurretWall`)
|
||||
|
||||
**ECSWorld.java**
|
||||
- 保留:`entities`, `players`, `playerIdToEntity`, `map`, `systems`, `lock`
|
||||
- 移除:`zombies`, `playerBullets`, `zombieBullets`, `loots`, `fireZones`, `turrets`, `wallEntities`
|
||||
- 移除:所有对象池
|
||||
- 移除:`createZombieEntity()`, `createBulletEntity()`, `createGrenadeEntity()`, `createMolotovEntity()`, `createLootEntity()`, `createFireZoneEntity()`, `createTurretEntity()`
|
||||
- 保留:`createPlayerEntity()`(精简版,无 WeaponState/RespawnState)
|
||||
- 保留:`update(dt)` — 清理临时数据,遍历系统执行
|
||||
|
||||
**GameService.java**
|
||||
- `startGame()`:仅注册 `PlayerInputSystem` 和 `StateSyncSystem`(移除其他10个系统)
|
||||
- 保留:`processPlayerInput()`, `stopGame()`
|
||||
|
||||
**PlayerInputSystem.java**
|
||||
- 保留:移动(dx/dy * speed)、逐轴墙壁碰撞、朝向计算
|
||||
- 移除:武器射击逻辑(如有)
|
||||
|
||||
**StateSyncSystem.java**
|
||||
- 精简广播为:`{players: [{id, x, y, angle, health}], gameTime}`
|
||||
- 移除:zombies, bullets, zombieBullets, loots, explosions, removedBullets, waveNumber, score
|
||||
|
||||
**Room.java / RoomManager.java / GameLoop.java / MessageUtils.java**
|
||||
- 保持原样(或微调清理)
|
||||
|
||||
**GameWebSocketServer.java**
|
||||
- 保留:`onMessage()` 分发、房间 CRUD 处理、`handlePlayerInput()`、`broadcastGameState()`
|
||||
- 可选移除:房间列表广播定时器(或保留用于大厅刷新)
|
||||
|
||||
### 1.3 地图数据
|
||||
- 保留 `maps/d540209a.json` 格式(walls + playerSpawns + zombieSpawns)
|
||||
- 复制一份到 `zp2/maps/`
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:前端骨架
|
||||
|
||||
### 2.1 项目结构
|
||||
```
|
||||
zp2/frontend/
|
||||
├── package.json # 同样依赖(three, vite)
|
||||
├── vite.config.js # 同样代理配置
|
||||
├── index.html # 同样最小化外壳
|
||||
└── src/
|
||||
├── main.js # 精简:大厅 → 游戏切换
|
||||
├── style.css # 仅大厅 + 房间 + 画布样式
|
||||
├── game/
|
||||
│ ├── engine.js # 精简:仅玩家,无僵尸/子弹/战斗
|
||||
│ └── scene.js # 精简:仅地图 + 玩家,无特效
|
||||
├── network/
|
||||
│ └── client.js # 保持原样
|
||||
├── ui/
|
||||
│ └── lobby.js # 保持原样(房间管理 UI)
|
||||
└── utils/
|
||||
├── constants.js # 精简:仅地图 + 玩家 + 网络常量
|
||||
├── grid.js # 精简:仅 Grid 类 + generateDefaultMap
|
||||
└── input.js # 精简:仅移动 + 瞄准
|
||||
```
|
||||
|
||||
### 2.2 关键变更
|
||||
|
||||
**constants.js**
|
||||
- 保留:`GRID_SIZE`, `CELL_SIZE`, `PLAYER_SIZE`, `TICK_RATE`, `TICK_INTERVAL`
|
||||
- 保留:`PLAYER_CONFIG`(MAX_HEALTH, SPEED)
|
||||
- 保留:`MSG_TYPE`(所有房间 + 游戏消息类型)
|
||||
- 移除:`WEAPONS`, `WEAPON_CONFIG`, `ZOMBIE_CONFIG`, `ZOMBIE_SIZE`
|
||||
|
||||
**grid.js**
|
||||
- 保留:`Grid` 类的 `parseMap()`, `isWall()`, `worldToGrid()`, `gridToWorld()`, `isWalkable()`
|
||||
- 保留:`generateDefaultMap()`(备用默认地图)
|
||||
- 移除:`findPath()`, `getSpawnPoints()`, `isSpawnPoint()`
|
||||
|
||||
**input.js**
|
||||
- 保留:`attach()`, `detach()`, `getMovement()`, `buildInputState()`(仅移动 + 瞄准)
|
||||
- 移除:`getSelectedWeapon()`, 武器快捷键绑定
|
||||
- 精简 `buildInputState()` 返回 `{seq, dx, dy, aimX, aimY}`(无 firing, weaponIndex, grenade 字段)
|
||||
|
||||
**engine.js(约666→250行)**
|
||||
- 保留:`connect()`, `start()`, `stop()`, `_loop()`, `_tick()`
|
||||
- 保留:`_applyLocalPrediction()` — 带碰撞的移动预测
|
||||
- 保留:`_reconcileLocalPlayer()` — 服务器校正
|
||||
- 保留:`_initPlayers()`, `_addPlayer()`, `_removePlayer()`
|
||||
- 精简 `_processServerState()`:仅同步玩家位置,移除所有僵尸/子弹/掉落物/炮塔/特效处理
|
||||
- 移除:`_handleGrenadeCharge()`, `_checkBulletHit()`, 武器状态, 手雷状态
|
||||
- 移除:`zombies`, `bullets`, `zombieBullets`, `loots`, `turrets` 映射表
|
||||
|
||||
**scene.js(约1331→250行)**
|
||||
- 保留:构造函数(场景、相机、渲染器、灯光、窗口自适应)
|
||||
- 保留:`buildMap(mapData)` — 地板 + 墙壁
|
||||
- 保留:`createPlayerModel()`, `addPlayer()`, `removePlayer()`, `updatePlayer()`
|
||||
- 保留:`updateCamera()`, `getMouseGroundPos()`, `render()`
|
||||
- 移除:所有僵尸渲染(约180行)
|
||||
- 移除:所有子弹渲染(约230行)
|
||||
- 移除:所有特效(约250行)
|
||||
- 移除:炮塔、掉落物、坚果墙、手雷目标指示器(约215行)
|
||||
- 移除:`updateEffects()`(或简化为空)
|
||||
|
||||
**lobby.js** — 保持原样
|
||||
|
||||
**main.js**
|
||||
- 保留:App 类、大厅绑定、游戏开始切换
|
||||
- 移除:HUD 初始化、设置界面初始化
|
||||
- 移除:`_updateHUD()` 调用
|
||||
|
||||
**style.css**
|
||||
- 保留:大厅样式、房间样式、画布容器样式
|
||||
- 移除:HUD 样式、武器面板、手雷充能、击杀信息、设置弹窗
|
||||
|
||||
---
|
||||
|
||||
## 阶段三:清理与验证
|
||||
|
||||
### 3.1 消息协议验证
|
||||
确保以下消息端到端正常工作:
|
||||
- `CREATE_ROOM` / `JOIN_ROOM` / `LEAVE_ROOM` / `READY` / `START_GAME`
|
||||
- `ROOM_LIST` / `ROOM_STATE` / `GAME_STARTED` / `ERROR`
|
||||
- `PLAYER_INPUT`(仅移动:dx, dy, aimX, aimY, seq)
|
||||
- `GAME_STATE`(仅玩家数据)
|
||||
|
||||
### 3.2 测试流程
|
||||
1. 启动后端:`mvn package && java -jar target/*.jar`
|
||||
2. 启动前端:`npm run dev`
|
||||
3. 打开两个浏览器标签页
|
||||
4. 标签页1:创建房间 → 标签页2:加入房间 → 标签页1:开始游戏
|
||||
5. 两个玩家应能在 32x32 地图上互相看到对方移动
|
||||
6. WASD 移动响应流畅(客户端预测)
|
||||
7. 玩家与墙壁碰撞正确
|
||||
|
||||
---
|
||||
|
||||
## 待创建文件清单(共36个)
|
||||
|
||||
### 后端(23个文件)
|
||||
| 序号 | 文件 | 来源 |
|
||||
|------|------|------|
|
||||
| 1 | `backend/pom.xml` | 从 zp1 复制 |
|
||||
| 2 | `GameServerMain.java` | 精简版 |
|
||||
| 3 | `model/Constants.java` | 精简版 |
|
||||
| 4 | `model/Room.java` | 基本保持 |
|
||||
| 5 | `model/PlayerInfo.java` | 基本保持 |
|
||||
| 6 | `model/GameMap.java` | 精简版(无流向场) |
|
||||
| 7 | `model/MapData.java` | 基本保持 |
|
||||
| 8 | `model/StaticWall.java` | 基本保持 |
|
||||
| 9 | `ecs/System.java` | 保持 |
|
||||
| 10 | `ecs/ECSWorld.java` | 精简版 |
|
||||
| 11 | `ecs/components/Position.java` | 保持 |
|
||||
| 12 | `ecs/components/PlayerInput.java` | 精简版 |
|
||||
| 13 | `ecs/components/Health.java` | 精简版 |
|
||||
| 14 | `ecs/components/Collision.java` | 保持 |
|
||||
| 15 | `ecs/components/RenderInfo.java` | 精简版 |
|
||||
| 16 | `systems/PlayerInputSystem.java` | 基本保持 |
|
||||
| 17 | `systems/StateSyncSystem.java` | 精简版 |
|
||||
| 18 | `server/GameWebSocketServer.java` | 精简版 |
|
||||
| 19 | `server/RoomManager.java` | 基本保持 |
|
||||
| 20 | `server/GameService.java` | 精简版 |
|
||||
| 21 | `server/GameLoop.java` | 基本保持 |
|
||||
| 22 | `server/MessageUtils.java` | 保持 |
|
||||
| 23 | `server/MapStorage.java` | 基本保持 |
|
||||
|
||||
### 前端(12个文件)
|
||||
| 序号 | 文件 | 来源 |
|
||||
|------|------|------|
|
||||
| 24 | `frontend/package.json` | 保持 |
|
||||
| 25 | `frontend/vite.config.js` | 保持 |
|
||||
| 26 | `frontend/index.html` | 保持 |
|
||||
| 27 | `frontend/src/main.js` | 精简版 |
|
||||
| 28 | `frontend/src/style.css` | 精简版 |
|
||||
| 29 | `frontend/src/game/engine.js` | 精简版 |
|
||||
| 30 | `frontend/src/game/scene.js` | 精简版 |
|
||||
| 31 | `frontend/src/network/client.js` | 基本保持 |
|
||||
| 32 | `frontend/src/ui/lobby.js` | 保持 |
|
||||
| 33 | `frontend/src/utils/constants.js` | 精简版 |
|
||||
| 34 | `frontend/src/utils/grid.js` | 精简版 |
|
||||
| 35 | `frontend/src/utils/input.js` | 精简版 |
|
||||
|
||||
### 数据文件(1个)
|
||||
| 序号 | 文件 | 来源 |
|
||||
|------|------|------|
|
||||
| 36 | `maps/d540209a.json` | 从 zp1 复制 |
|
||||
|
||||
---
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
|------|------|------|
|
||||
| 技术栈 | Java 17 + Vite + Three.js | 与 zp1 一致,迁移成本最低 |
|
||||
| ECS 架构 | 保留 | 结构清晰,便于后续扩展 |
|
||||
| 客户端预测 | 保留 | 移动手感更好,代码量不大 |
|
||||
| 流向场寻路 | 移除 | 仅僵尸 AI 需要 |
|
||||
| 地图格式 | 保持 JSON | 兼容现有地图文件 |
|
||||
| WebSocket 协议 | 保持相同消息类型 | 前后端保持同步 |
|
||||
| 大厅 UI | 保持原样 | 房间管理是核心功能 |
|
||||
| HUD/设置 | 移除 | 不在本次范围内 |
|
||||
| 地图编辑器 | 移除 | 不在本次范围内 |
|
||||
|
||||
## 风险点
|
||||
- **StateSyncSystem**:精简时需小心处理消息格式,确保前端能正确解析
|
||||
- **ECSWorld**:移除组件映射表后,需检查所有引用这些组件的系统
|
||||
- **客户端预测**:engine.js 中的校正逻辑引用了武器状态,需验证精简版是否正常工作
|
||||
143
docs/migration-plan.md
Normal file
143
docs/migration-plan.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# ZP1 Minimal Migration Plan
|
||||
|
||||
## Goal
|
||||
Rewrite zp1 into zp2, keeping only: room management, map loading, player movement, and minimal frontend display. Same tech stack (Java + Vite/Three.js), ECS architecture, client prediction.
|
||||
|
||||
## Scope
|
||||
- **Keep**: Room system, map loading/rendering, player movement (WASD), client prediction, WebSocket communication
|
||||
- **Strip**: Zombies, weapons/bullets, turrets, fire zones, loots, explosions/effects, HUD, settings, map designer, template system, object pool
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend Skeleton
|
||||
|
||||
### 1.1 Project structure
|
||||
```
|
||||
zp2/backend/
|
||||
├── pom.xml
|
||||
└── src/main/java/com/zombie/game/
|
||||
├── GameServerMain.java
|
||||
├── model/
|
||||
│ ├── Constants.java
|
||||
│ ├── Room.java
|
||||
│ ├── PlayerInfo.java
|
||||
│ ├── GameMap.java
|
||||
│ ├── MapData.java
|
||||
│ └── StaticWall.java
|
||||
├── server/
|
||||
│ ├── GameWebSocketServer.java
|
||||
│ ├── RoomManager.java
|
||||
│ ├── GameService.java
|
||||
│ ├── GameLoop.java
|
||||
│ ├── MessageUtils.java
|
||||
│ └── MapStorage.java
|
||||
├── ecs/
|
||||
│ ├── ECSWorld.java
|
||||
│ ├── System.java
|
||||
│ └── components/
|
||||
│ ├── Position.java
|
||||
│ ├── PlayerInput.java
|
||||
│ ├── Health.java
|
||||
│ ├── Collision.java
|
||||
│ └── RenderInfo.java
|
||||
└── systems/
|
||||
├── PlayerInputSystem.java
|
||||
└── StateSyncSystem.java
|
||||
```
|
||||
|
||||
### 1.2 Key changes
|
||||
|
||||
**Constants.java** — Keep `GRID_SIZE`, `TICK_RATE`, `PLAYER_SIZE`, `PLAYER_SPEED`, `MSG_*`. Remove zombie/weapon/loot constants.
|
||||
|
||||
**GameMap.java** — Keep `loadFromJson()`, `isWall()`, `isWalkable()`, `getCells()`, `getSpawnPoints()`. Remove `FlowField` entirely.
|
||||
|
||||
**ECSWorld.java** — Keep `entities`, `players`, `playerIdToEntity`, `map`, `systems`, `lock`. Remove zombies/bullets/loots/turrets/fireZones. Remove object pools. Keep `createPlayerEntity()` only.
|
||||
|
||||
**GameService.java** — Register only `PlayerInputSystem` + `StateSyncSystem`.
|
||||
|
||||
**StateSyncSystem.java** — Broadcast `{players: [{id, x, y, angle, health}], gameTime}` only.
|
||||
|
||||
**GameWebSocketServer.java** — Keep room CRUD + player input + state broadcast.
|
||||
|
||||
### 1.3 Map data
|
||||
- Copy `maps/d540209a.json` (walls + playerSpawns + zombieSpawns)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Frontend Skeleton
|
||||
|
||||
### 2.1 Project structure
|
||||
```
|
||||
zp2/frontend/
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
├── index.html
|
||||
└── src/
|
||||
├── main.js
|
||||
├── style.css
|
||||
├── game/
|
||||
│ ├── engine.js
|
||||
│ └── scene.js
|
||||
├── network/
|
||||
│ └── client.js
|
||||
├── ui/
|
||||
│ └── lobby.js
|
||||
└── utils/
|
||||
├── constants.js
|
||||
├── grid.js
|
||||
└── input.js
|
||||
```
|
||||
|
||||
### 2.2 Key changes
|
||||
|
||||
**constants.js** — Keep `GRID_SIZE`, `PLAYER_SIZE`, `TICK_RATE`, `TICK_INTERVAL`, `PLAYER_CONFIG`, `MSG_TYPE`. Remove `WEAPONS`, `WEAPON_CONFIG`, `ZOMBIE_CONFIG`.
|
||||
|
||||
**grid.js** — Keep `Grid` class (`parseMap`, `isWall`, `worldToGrid`, `gridToWorld`, `isWalkable`). Remove `findPath`, `getSpawnPoints`.
|
||||
|
||||
**input.js** — Keep `attach/detach`, `getMovement`, `buildInputState` (movement + aim only). Remove weapon bindings.
|
||||
|
||||
**engine.js (~666→~250 lines)** — Keep connect, loop, tick, local prediction, reconciliation. Strip zombie/bullet/loot/turret processing.
|
||||
|
||||
**scene.js (~1331→~250 lines)** — Keep constructor, lighting, `buildMap`, player rendering, camera, `getMouseGroundPos`. Remove zombie/bullet/effect/turret/loot rendering.
|
||||
|
||||
**lobby.js** — Keep as-is.
|
||||
|
||||
**main.js** — Keep lobby wiring + game transition. Remove HUD/settings.
|
||||
|
||||
**style.css** — Keep lobby + room + canvas styles. Remove HUD/weapon/kill-feed styles.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cleanup & Verification
|
||||
|
||||
### 3.1 Message protocol
|
||||
- `CREATE_ROOM` / `JOIN_ROOM` / `LEAVE_ROOM` / `READY` / `START_GAME`
|
||||
- `ROOM_LIST` / `ROOM_STATE` / `GAME_STARTED` / `ERROR`
|
||||
- `PLAYER_INPUT` (dx, dy, aimX, aimY, seq)
|
||||
- `GAME_STATE` (players only)
|
||||
|
||||
### 3.2 Test flow
|
||||
1. `mvn package && java -jar target/*.jar`
|
||||
2. `npm run dev`
|
||||
3. Open two tabs → Create room → Join room → Start game
|
||||
4. Both players see each other move, WASD responsive, wall collision works
|
||||
|
||||
---
|
||||
|
||||
## Files to Create (36 total)
|
||||
|
||||
| # | File | Source |
|
||||
|---|------|--------|
|
||||
| 1-23 | Backend Java files (see 1.1) | Simplified from zp1 |
|
||||
| 24-35 | Frontend JS/HTML/CSS (see 2.1) | Simplified from zp1 |
|
||||
| 36 | `maps/d540209a.json` | Copy from zp1 |
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Choice | Reason |
|
||||
|----------|--------|--------|
|
||||
| Tech stack | Java 17 + Vite + Three.js | Same as zp1 |
|
||||
| ECS | Keep | Structure preserved |
|
||||
| Client prediction | Keep | Better movement feel |
|
||||
| Flow field | Remove | Zombie-only feature |
|
||||
| HUD/Settings | Remove | Not in scope |
|
||||
Reference in New Issue
Block a user