# 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` | 所有活跃实体 | | `players` | `LinkedHashSet` | 玩家实体集合 | | `positions` | `Map` | 位置组件 | | `healths` | `Map` | 生命值组件 | | `collisions` | `Map` | 碰撞组件 | | `renderInfos` | `Map` | 渲染组件 | | `playerInputs` | `Map` | 输入组件 | | `systems` | `List` | 系统列表 | | `playerIdToEntity` | `Map` | 玩家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 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` | 键盘/鼠标输入 |