20 KiB
20 KiB
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: 房主 playerIdplayers: 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 接口
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
构建游戏状态快照:
{
"players": [
{
"id": "player-uuid",
"x": 12.5,
"y": 8.3,
"angle": 1.57,
"health": 100,
"lastProcessedSeq": 42
}
],
"gameTime": 12.5
}
3.7 地图系统
GameMap 从 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
核心数据结构
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等
消息格式:
{
"type": "player_input",
"data": {"dx": 1, "dy": 0, "aimX": 10, "aimY": 5, "seq": 42}
}
4.5 输入管理 — input.js
键盘输入:
- WASD / 方向键 → 移动向量
- 对角线归一化(×0.7071)
鼠标输入:
- 位置追踪 (clientX, clientY)
- 左右键状态
buildInputState:
{
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 == 1worldToGrid(wx, wy): 世界坐标 → 网格坐标isWalkable(wx, wy, size): 检查四个角是否可通行
generateDefaultMap:
- 边界墙(最外层)
- 预设墙体段(14段)
- 4个角落出生点(周围自动清理墙体)
5. 网络协议
消息格式
所有消息使用统一的 {type, data} 信封格式:
{"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 |
键盘/鼠标输入 |