diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c5f3f6b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "interactive" +} \ No newline at end of file diff --git a/backend/FLOW_FIELD_NAVIAGTION.md b/backend/FLOW_FIELD_NAVIAGTION.md new file mode 100644 index 0000000..48dbc30 --- /dev/null +++ b/backend/FLOW_FIELD_NAVIAGTION.md @@ -0,0 +1,288 @@ +# 僵尸流向场寻路系统技术文档 + +## 1. 概述 + +本系统将僵尸的寻路算法从 A* 个体寻路改为基于距离场/流向场的群体寻路算法,显著提升了多僵尸同时寻路的效率和自然性。 + +### 1.1 核心优势 + +| 特性 | A* 算法 | 流向场算法 | +|------|---------|------------| +| 计算方式 | 每个僵尸独立计算 | 共享流向场,统一计算 | +| 多目标支持 | 需要为每个目标重新计算 | 自动支持多个玩家目标 | +| 性能 | O(n) 每僵尸 | O(m) 每帧 (m=地图格子数) | +| 动态适应 | 需要重新寻路 | 自动适应玩家移动 | + +## 2. 系统架构 + +### 2.1 核心组件 + +``` +GameWorld + ├── GameMap + │ ├── distanceField[][] (距离场) + │ ├── flowFieldX[][] (流向场X分量) + │ ├── flowFieldY[][] (流向场Y分量) + │ └── flowFieldValid (流向场有效标志) + │ + └── Zombie[] + ├── x, y (当前位置) + ├── targetX, targetY (目标网格中心) + ├── hasTarget (是否有目标) + ├── reservedGridX/Y (预留格子) + └── hasReservation (是否有预留) +``` + +### 2.2 数据流 + +``` +每帧更新流程: +1. GameWorld.updateFlowField() + └── 收集所有存活玩家位置 + └── 调用 GameMap.updateFlowField() + +2. GameMap.updateFlowField() + └── BFS 扩散计算距离场 + └── 基于距离场计算流向场 + +3. Zombie.move() + └── 查询流向场获取移动方向 + └── 检查格子占用/预留 + └── 移动到网格中心 + └── 碰撞分离处理 +``` + +## 3. 距离场与流向场 + +### 3.1 距离场 (Distance Field) + +使用 BFS (广度优先搜索) 算法计算每个格子到最近玩家的距离。 + +```java +// 初始化:所有玩家位置距离为0,加入队列 +for (float[] pos : playerPositions) { + int px = (int) Math.floor(pos[0]); + int py = (int) Math.floor(pos[1]); + distanceField[py][px] = 0; + queue.add(new int[]{px, py}); +} + +// BFS 扩散 +while (!queue.isEmpty()) { + int[] current = queue.poll(); + for (int[] dir : dirs) { + int nx = current[0] + dir[0]; + int ny = current[1] + dir[1]; + + // 检查墙壁和角落阻挡 + if (dir[0] != 0 && dir[1] != 0) { + if (isWall(cx + dir[0], cy) || isWall(cx, cy + dir[1])) { + continue; + } + } + + float moveCost = (dir[0] != 0 && dir[1] != 0) ? 1.414f : 1.0f; + float newDist = currentDist + moveCost; + + if (newDist < distanceField[ny][nx]) { + distanceField[ny][nx] = newDist; + queue.add(new int[]{nx, ny}); + } + } +} +``` + +**特性**: +- 支持 8 方向移动(对角线代价为 √2) +- 正确处理角落碰撞(斜向移动时检查相邻两格是否都为墙) + +### 3.2 流向场 (Flow Field) + +基于距离场计算每个格子的最优流向方向。 + +```java +// 对于每个非墙格子,找到距离场梯度下降方向 +for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (isWall(x, y)) continue; + + float bestDist = distanceField[y][x]; + float bestDirX = 0, bestDirY = 0; + + // 检查8个方向邻居 + for (int[] dir : dirs) { + int nx = x + dir[0]; + int ny = y + dir[1]; + + if (distanceField[ny][nx] < bestDist) { + bestDist = distanceField[ny][nx]; + // 单位化方向向量 + float len = (float) Math.sqrt(dir[0]*dir[0] + dir[1]*dir[1]); + bestDirX = dir[0] / len; + bestDirY = dir[1] / len; + } + } + + flowFieldX[y][x] = bestDirX; + flowFieldY[y][x] = bestDirY; + } +} +``` + +## 4. 僵尸移动逻辑 + +### 4.1 网格中心对齐 + +与 A* 寻路类似,僵尸朝向网格中心点 (`gridX + 0.5f, gridY + 0.5f`) 移动,避免飘忽不定。 + +```java +// 选择下一个目标网格 +if (!hasTarget || centerDist < 0.15f) { + float[] flowDir = map.getFlowDirection(x, y); + int nextGridX = currentGridX + (int) Math.round(dirX); + int nextGridY = currentGridY + (int) Math.round(dirY); + + // 目标 = 网格中心 + targetX = nextGridX + 0.5f; + targetY = nextGridY + 0.5f; + hasTarget = true; +} +``` + +### 4.2 格子预留机制 + +防止多个僵尸同时选择同一格子导致死锁。 + +```java +// 预留目标格子 +reservedGridX = nextGridX; +reservedGridY = nextGridY; +hasReservation = true; + +// 检查格子是否被占用或预留 +private boolean isGridOccupiedOrReserved(int gridX, int gridY, Collection others) { + for (Zombie other : others) { + // 检查当前占用 + if (otherGridX == gridX && otherGridY == gridY) return true; + + // 检查预留 + if (other.hasReservation() && + other.getReservedGridX() == gridX && + other.getReservedGridY() == gridY) { + return true; + } + } + return false; +} +``` + +### 4.3 替代路径选择 + +当首选格子被占用时,寻找最接近目标方向的替代路径。 + +```java +private int[] findAlternativeDirection(...) { + // 收集所有可行方向 + for (int[] dir : allDirs) { + if (isGridOccupiedOrReserved(nx, ny, others)) continue; + + float dotProduct = dir[0] * dirX + dir[1] * dirY; + candidates.add(new int[]{nx, ny, (int) (dotProduct * 1000)}); + } + + // 按点积排序(最接近原始方向优先) + candidates.sort((a, b) -> b[2] - a[2]); + return candidates.isEmpty() ? null : candidates.get(0); +} +``` + +### 4.4 碰撞分离 + +移动后处理僵尸之间的物理分离,避免重叠。 + +```java +float minSeparationDist = Constants.ZOMBIE_SIZE; // 0.8f + +for (Zombie other : others) { + float sepDist = distanceTo(other); + + if (sepDist < minSeparationDist && sepDist > 0.01f) { + float overlap = minSeparationDist - sepDist; + float pushX = (sepDx / sepDist) * overlap * 0.5f; + float pushY = (sepDy / sepDist) * overlap * 0.5f; + + // 尝试推开 + if (map.isWalkable(x + pushX, y + pushY, ZOMBIE_SIZE)) { + x += pushX; + y += pushY; + } + } +} +``` + +### 4.5 斜向移动防卡墙 + +处理僵尸斜向移动时碰到墙角卡住的问题。 + +```java +// 检测是否斜向穿过墙角 +if (checkX != checkCurrentX && checkY != checkCurrentY) { + boolean wallInX = map.isWall(checkX, checkCurrentY); + boolean wallInY = map.isWall(checkCurrentX, checkY); + + if (wallInX || wallInY) { + // 尝试沿单轴滑动 + if (!wallInX && canMoveX) { + x = newX; + } else if (!wallInY && canMoveY) { + y = newY; + } + } +} +``` + +## 5. 移动顺序 + +僵尸按 ID 顺序移动,确保低 ID 僵尸优先选择路径。 + +```java +// GameWorld.updateZombies() +List sortedZombies = new ArrayList<>(zombies.values()); +sortedZombies.sort((a, b) -> Integer.compare(a.getId(), b.getId())); + +for (Zombie z : sortedZombies) { + z.move(map, dt, zombies.values()); +} +``` + +## 6. 关键常量 + +| 常量 | 值 | 说明 | +|------|-----|------| +| `GRID_SIZE` | 32 | 地图尺寸 32×32 格 | +| `ZOMBIE_SIZE` | 0.8f | 僵尸碰撞半径 | +| 网格中心阈值 | 0.15f | 到达网格中心后重新选择方向 | +| 分离距离 | ZOMBIE_SIZE | 僵尸开始分离的距离 | +| 分离推力 | 0.5f | 每次分离移动的比例 | + +## 7. 潜在问题与解决方案 + +### 7.1 僵尸聚集在门口 + +**原因**: 流向场在门口处汇聚。 + +**解决方案**: 可在距离场中加入惩罚项,让僵尸分散。 + +### 7.2 低帧率下移动不稳定 + +**原因**: 流向场每帧更新,帧率低时变化剧烈。 + +**解决方案**: 考虑增加流向场更新间隔或使用插值平滑。 + +## 8. 性能考虑 + +- 流向场计算: O(width × height × 8) ≈ O(8000) 每帧 +- 单僵尸移动: O(1) 查询流向场 +- 总复杂度: O(m) + O(n),其中 m=格子数,n=僵尸数 + +相比 A* 每帧 O(n × 路径长度),大幅提升性能。 diff --git a/backend/PROJECT_DOCUMENTATION.md b/backend/PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..37bf716 --- /dev/null +++ b/backend/PROJECT_DOCUMENTATION.md @@ -0,0 +1,399 @@ +# Zombie Crisis 3 - 项目技术文档 + +## 1. 项目概述 + +Zombie Crisis 3 是一款多人在线僵尸射击游戏,采用客户端-服务器架构,支持最多 4 名玩家同时游戏。 + +### 1.1 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 后端 | Java + Maven | 游戏逻辑服务器 | +| 前端 | JavaScript + Vite | Web 客户端 | +| 渲染 | Three.js | 3D 图形渲染 | +| 通信 | WebSocket | 实时双向通信 | +| 协议 | JSON | 消息序列化 | + +### 1.2 项目结构 + +``` +zp1/ +├── backend/ # Java 游戏服务器 +│ ├── pom.xml # Maven 配置 +│ └── src/main/java/com/zombie/game/ +│ ├── GameServerMain.java # 程序入口 +│ ├── model/ # 数据模型 +│ │ ├── GameWorld.java # 游戏世界主类 +│ │ ├── GameMap.java # 地图与流向场 +│ │ ├── Zombie.java # 僵尸实体 +│ │ ├── Player.java # 玩家实体 +│ │ ├── Bullet.java # 子弹实体 +│ │ ├── Loot.java # 掉落物 +│ │ ├── Room.java # 游戏房间 +│ │ └── Constants.java # 常量定义 +│ └── server/ +│ ├── GameWebSocketServer.java # WebSocket 服务器 +│ └── GameLoop.java # 游戏循环 +│ +├── frontend/ # JavaScript 客户端 +│ ├── package.json +│ ├── vite.config.js +│ ├── index.html +│ └── src/ +│ ├── main.js # 应用入口 +│ ├── game/ +│ │ ├── engine.js # 游戏引擎 +│ │ └── scene.js # Three.js 场景 +│ ├── network/ +│ │ └── client.js # WebSocket 客户端 +│ ├── ui/ +│ │ ├── lobby.js # 大厅界面 +│ │ ├── hud.js # 游戏 HUD +│ │ └── settings.js # 设置界面 +│ └── utils/ +│ ├── constants.js # 前端常量 +│ ├── input.js # 输入管理 +│ └── grid.js # 网格工具 +│ +└── FLOW_FIELD_NAVIGATION.md # 流向场寻路文档 +``` + +--- + +## 2. 系统架构 + +### 2.1 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 客户端 │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ LobbyUI │ │ HUD │ │ Scene │ │ Network │ │ +│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ +│ └────────────┴────────────┴────────────┘ │ +│ │ │ +│ ┌─────┴─────┐ │ +│ │GameEngine │ │ +│ └─────┬─────┘ │ +└──────────────────────────┼──────────────────────────────────┘ + │ WebSocket (ws://:8080) + │ +┌──────────────────────────┼──────────────────────────────────┐ +│ │ 服务器 │ +│ ┌─────┴─────┐ │ +│ │GameWebSocketServer│ │ +│ └─────┬─────┘ │ +│ ┌──────────────────┼──────────────────┐ │ +│ ┌────┴────┐ ┌─────┴─────┐ ┌────┴────┐ │ +│ │ Room │ │ GameLoop │ │ GameWorld│ │ +│ │ Manager │ │ (30 TPS) │ │ │ │ +│ └─────────┘ └─────┬─────┘ └────┬────┘ │ +│ │ │ │ +│ ┌─────┴─────────────────┴─────┐ │ +│ │ Model Entities │ │ +│ │ Player | Zombie | Bullet │ │ +│ └──────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 游戏循环 + +服务器以固定 30 TPS (Tick Per Second) 运行游戏逻辑: + +``` +每帧 (约33ms): + 1. updateFlowField() - 更新流向场 + 2. updateZombies() - 更新所有僵尸 + 3. updateBullets() - 更新所有子弹 + 4. updateZombieBullets() - 更新僵尸子弹 + 5. checkBulletCollisions()- 子弹碰撞检测 + 6. checkZombieAttacks() - 僵尸攻击检测 + 7. checkLootCollection() - 拾取物收集 + 8. broadcastGameState() - 广播游戏状态 +``` + +--- + +## 3. 核心模块详解 + +### 3.1 GameWorld + +游戏世界主类,管理所有游戏实体和逻辑。 + +**主要职责**: +- 管理玩家、僵尸、子弹、掉落物集合 +- 每帧调用所有实体的更新方法 +- 处理实体间的碰撞检测 +- 生成僵尸和管理难度 + +**关键方法**: +```java +public void update(float dt) +private void updateFlowField() // 更新流向场 +private void updateZombies(float dt) // 更新僵尸 +private void updateBullets(float dt) // 更新子弹 +private void checkBulletCollisions() // 子弹碰撞 +private void checkZombieAttacks() // 僵尸攻击 +private void fireWeapon(Player, aimX, aimY) // 武器射击 +``` + +### 3.2 GameMap + +地图类,包含 32×32 格子地图和流向场寻路系统。 + +**流向场算法优势**: +- 一次计算,所有僵尸共享 +- 自动适应多个玩家目标 +- O(m) 复杂度,m=地图格子数 + +### 3.3 Zombie + +僵尸实体,采用流向场移动 + 碰撞解决机制。 + +**移动系统**: +1. 查询流向场获取移动方向 +2. 选择目标网格中心 +3. 检查格子预留/占用 +4. 移动并处理碰撞分离 + +### 3.4 Player + +玩家实体,支持客户端预测和服务器校验。 + +**输入处理**: +```java +applyMovement(dx, dy, map) // 移动 +setAngle(aimX, aimY) // 瞄准方向 +fireWeapon(player, aimX, aimY) // 射击 +``` + +### 3.5 Bullet + +子弹实体,支持两种模式: + +| 模式 | 武器 | 特点 | +|------|------|------| +| 弹道 | 手枪/机枪/霰弹枪 | 直线飞行,有射程限制 | +| 抛物线 | 手雷 | 弧线弹道,触地爆炸 | + +### 3.6 Room + +游戏房间,管理玩家列表和游戏状态。 + +**房间状态**: +- 等待中:玩家加入、准备 +- 游戏中:所有玩家加载游戏 +- 结束:所有玩家离开或游戏结束 + +--- + +## 4. 通信协议 + +### 4.1 消息格式 + +所有消息使用 JSON 格式: +```json +{ + "type": "MESSAGE_TYPE", + "data": { ... } +} +``` + +### 4.2 消息类型 + +#### 客户端 → 服务器 + +| 消息类型 | 说明 | 数据 | +|---------|------|------| +| `create_room` | 创建房间 | `{playerName}` | +| `join_room` | 加入房间 | `{roomId, playerName}` | +| `leave_room` | 离开房间 | - | +| `ready` | 准备状态切换 | - | +| `start_game` | 开始游戏 | - | +| `player_input` | 玩家输入 | `{dx, dy, aimX, aimY, firing, weaponIndex, seq}` | + +#### 服务器 → 客户端 + +| 消息类型 | 说明 | 数据 | +|---------|------|------| +| `room_list` | 房间列表 | `{rooms: [{id, hostName, playerCount}]}` | +| `room_state` | 房间状态 | `{roomId, hostId, players: [...]}` | +| `game_started` | 游戏开始 | `{playerId, mapData, players: [...]}` | +| `game_state` | 游戏状态 | `{players, zombies, bullets, ...}` | +| `error` | 错误消息 | `{message}` | + +### 4.3 游戏状态同步 + +每帧广播的游戏状态包含: +```java +{ + "players": [{id, x, y, angle, health, weaponIndex}], + "zombies": [{id, x, y, angle, health, isElite}], + "bullets": [{id, x, y, z, angle, weapon, ownerId}], + "zombieBullets": [...], + "loots": [{id, x, y, type}], + "explosions": [{x, y, radius}], + "removedBullets": [id1, id2, ...], + "gameTime": 120.5, + "waveNumber": 3, + "score": 1250 +} +``` + +--- + +## 5. 武器系统 + +### 5.1 武器配置 + +| 武器 | 伤害 | 射速 | 弹药 | 特点 | +|------|------|------|------|------| +| 手枪 | 50 | 400ms | ∞ | 单发,精准 | +| 机枪 | 15 | 100ms | 100 | 自动,略微散布 | +| 霰弹枪 | 20×6 | 800ms | 20 | 6发弹丸,高散布 | +| 手雷 | 120 | 1500ms | 10 | 可蓄力,范围伤害 | + +### 5.2 手雷机制 + +- **蓄力**:长按射击键蓄力(最多2秒) +- **射程**:蓄力0-100%对应3-15格距离 +- **弹道**:抛物线飞行 +- **爆炸**:范围3格伤害 + +--- + +## 6. 僵尸系统 + +### 6.1 僵尸类型 + +| 类型 | 生命值 | 速度 | 特殊能力 | +|------|--------|------|----------| +| 普通 | 100+ | 2.0+ | 近战攻击 | +| 精英 | 800 | 1.5 | 远程射击 | + +### 6.2 精英僵尸攻击 + +- **攻击范围**:8格 +- **攻击间隔**:2秒 +- **子弹伤害**:30 +- **子弹速度**:6 + +### 6.3 难度递增 + +每30秒波次增加: +- 僵尸生命值 +20 +- 僵尸速度 +0.1 +- 生成间隔 -0.3秒(最低0.8秒) + +--- + +## 7. 前端渲染 + +### 7.1 Three.js 场景 + +**摄像机**: +- 透视摄像机,45度FOV +- 偏移量 (0, 25, 18) +- 平滑跟随玩家 + +**光照**: +- 环境光:0x404060 +- 方向光:0xffeedd(带阴影) +- 点光源:0xff4400 + +### 7.2 实体模型 + +| 实体 | 几何体 | 颜色 | +|------|--------|------| +| 玩家 | 圆柱体+球头 | 蓝/红/绿/黄 | +| 普通僵尸 | 圆柱体+球头 | 0x446633 | +| 精英僵尸 | 同上+发光球 | 0x882222 | +| 墙体 | 立方体 | 0x555577 | + +### 7.3 特效系统 + +- **爆炸**:白色闪光 + 多层球体 + 碎片 +- **枪口火焰**:黄色球体 + 光点 +- **击中效果**:红色球体 + 火花粒子 +- **掉落物**:旋转立方体 + +--- + +## 8. 客户端预测 + +### 8.1 预测机制 + +1. 客户端发送输入(含序列号) +2. 客户端立即本地预测执行 +3. 服务器执行并返回 `lastProcessedSeq` +4. 客户端回滚并重放未确认输入 + +### 8.2 输入结构 + +```javascript +{ + dx: -1~1, // 移动方向 X + dy: -1~1, // 移动方向 Y + aimX: worldX, // 瞄准位置 X + aimY: worldY, // 瞄准位置 Y + firing: boolean, // 是否射击 + weaponIndex: 0-3, // 武器索引 + grenadeCharge: 0-1, // 手雷蓄力 + grenadeReleased: boolean, + seq: number // 序列号 +} +``` + +--- + +## 9. 常量配置 + +### 9.1 服务器常量 (Java) + +```java +GRID_SIZE = 32 // 地图尺寸 +PLAYER_SIZE = 0.8f // 玩家碰撞半径 +ZOMBIE_SIZE = 0.8f // 僵尸碰撞半径 +PLAYER_SPEED = 5.0f // 玩家移动速度 +ZOMBIE_BASE_SPEED = 2.0f // 僵尸基础速度 +TICK_RATE = 30 // 服务器Tick率 +``` + +### 9.2 客户端常量 (JavaScript) + +与服务器保持一致的定义在 `constants.js`。 + +--- + +## 10. 启动与部署 + +### 10.1 服务器启动 + +```bash +cd backend +mvn package +java -jar target/zombie-crisis-server-1.0.0.jar [port] +# 默认端口 8080 +``` + +### 10.2 客户端启动 + +```bash +cd frontend +npm install +npm run dev +# 访问 http://localhost:5173 +``` + +### 10.3 依赖版本 + +- **Java**: 17+ +- **Maven**: 3.6+ +- **Node.js**: 16+ +- **Three.js**: (通过 npm) + +--- + +## 11. 相关文档 + +- [流向场寻路系统技术文档](./FLOW_FIELD_NAVIGATION.md) - 僵尸AI寻路详细说明 diff --git a/backend/pom.xml b/backend/pom.xml index 618c2b2..0d23ef9 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -16,6 +16,12 @@ + + org.projectlombok + lombok + 1.18.30 + provided + org.java-websocket Java-WebSocket @@ -35,6 +41,22 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + org.projectlombok + lombok + 1.18.30 + + + + org.apache.maven.plugins maven-jar-plugin diff --git a/backend/src/main/java/com/zombie/game/GameServerMain.java b/backend/src/main/java/com/zombie/game/GameServerMain.java index fc4e467..e4a2d7e 100644 --- a/backend/src/main/java/com/zombie/game/GameServerMain.java +++ b/backend/src/main/java/com/zombie/game/GameServerMain.java @@ -1,30 +1,52 @@ package com.zombie.game; import com.zombie.game.server.GameWebSocketServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +/** + * 游戏服务器主入口类 + * + * 职责: + * 1. 解析命令行参数(端口号) + * 2. 启动 WebSocket 游戏服务器 + * 3. 注册 JVM 关闭钩子,确保服务器优雅退出 + */ public class GameServerMain { + private static final Logger logger = LoggerFactory.getLogger(GameServerMain.class); + + /** + * 程序入口方法 + * + * @param args 命令行参数,第一个参数可选为端口号(默认 8080) + */ public static void main(String[] args) { + // 默认监听端口 int port = 8080; + + // 如果传入了端口号参数,尝试解析 if (args.length > 0) { try { port = Integer.parseInt(args[0]); } catch (NumberFormatException e) { - System.err.println("Invalid port number, using default: 8080"); + logger.warn("Invalid port number, using default: 8080"); } } + // 创建并启动 WebSocket 服务器 GameWebSocketServer server = new GameWebSocketServer(port); server.start(); - System.out.println("Zombie Crisis 3 Server started on port " + port); - System.out.println("Press Ctrl+C to stop the server"); + logger.info("Zombie Crisis 3 Server started on port {}", port); + logger.info("Press Ctrl+C to stop the server"); + // 注册关闭钩子:当 JVM 退出时(Ctrl+C 或系统信号),优雅地停止服务器 Runtime.getRuntime().addShutdownHook(new Thread(() -> { - System.out.println("Shutting down server..."); + logger.info("Shutting down server..."); try { server.stop(); } catch (Exception e) { - e.printStackTrace(); + logger.error("Error during server shutdown", e); } })); } diff --git a/backend/src/main/java/com/zombie/game/model/Bullet.java b/backend/src/main/java/com/zombie/game/model/Bullet.java index 46c6a03..c233106 100644 --- a/backend/src/main/java/com/zombie/game/model/Bullet.java +++ b/backend/src/main/java/com/zombie/game/model/Bullet.java @@ -1,7 +1,15 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 子弹/投掷物类 + * + * 管理玩家和僵尸发射的子弹、手榴弹等投掷物。 + * 支持普通子弹的直线飞行和手榴弹的抛物线轨迹。 + */ +@Getter public class Bullet { private int id; private float x, y; @@ -44,6 +52,19 @@ public class Bullet { this.targetY = y + (float) Math.cos(angle) * range; } + /** + * 构造函数 - 手榴弹 + * + * @param id 子弹ID + * @param startX 起始X坐标 + * @param startY 起始Y坐标 + * @param targetX 目标X坐标 + * @param targetY 目标Y坐标 + * @param flightDuration 飞行时长 + * @param damage 伤害值 + * @param ownerId 发射者ID + * @param explosionRadius 爆炸半径 + */ public Bullet(int id, float startX, float startY, float targetX, float targetY, float flightDuration, int damage, String ownerId, float explosionRadius) { this.id = id; @@ -71,21 +92,6 @@ public class Bullet { this.vz = 3.0f; } - public int getId() { return id; } - public float getX() { return x; } - public float getY() { return y; } - public float getZ() { return z; } - public float getVx() { return vx; } - public float getVy() { return vy; } - public int getDamage() { return damage; } - public String getOwnerId() { return ownerId; } - public String getWeapon() { return weapon; } - public boolean isExplosive() { return explosive; } - public float getExplosionRadius() { return explosionRadius; } - public float getTargetX() { return targetX; } - public float getTargetY() { return targetY; } - public boolean isGrenade() { return isGrenade; } - public boolean update(float dt, GameMap map) { if (isGrenade) { flightTime += dt; @@ -123,6 +129,14 @@ public class Bullet { } } + /** + * 检测子弹是否命中实体 + * + * @param ex 实体X坐标 + * @param ey 实体Y坐标 + * @param size 实体碰撞体大小 + * @return true 表示命中 + */ public boolean hitsEntity(float ex, float ey, float size) { float dx = x - ex; float dy = y - ey; @@ -130,6 +144,11 @@ public class Bullet { return dist < size / 2 + 0.1f; } + /** + * 将子弹状态转换为Map格式,用于网络传输 + * + * @return 包含子弹状态的Map + */ public Map toStateMap() { Map map = new LinkedHashMap<>(); map.put("id", id); diff --git a/backend/src/main/java/com/zombie/game/model/Constants.java b/backend/src/main/java/com/zombie/game/model/Constants.java index 2b1ec71..aece0d9 100644 --- a/backend/src/main/java/com/zombie/game/model/Constants.java +++ b/backend/src/main/java/com/zombie/game/model/Constants.java @@ -1,56 +1,141 @@ package com.zombie.game.model; +/** + * 游戏常量定义类 + * + * 集中管理所有游戏平衡性参数、消息类型常量。 + * 修改此文件中的数值可以调整游戏难度和行为。 + */ public class Constants { + + // ==================== 地图与基础参数 ==================== + + /** 地图网格尺寸(32×32 格子) */ public static final int GRID_SIZE = 32; + /** 玩家碰撞体大小 */ public static final float PLAYER_SIZE = 0.8f; + /** 僵尸碰撞体大小 */ public static final float ZOMBIE_SIZE = 0.8f; + /** 服务器逻辑帧率(每秒 tick 数) */ public static final int TICK_RATE = 30; + /** 每次 tick 的时间间隔(秒) */ public static final float TICK_INTERVAL = 1.0f / TICK_RATE; - public static final float PLAYER_SPEED = 5.0f; - public static final float PLAYER_MAX_HEALTH = 100; - public static final float PLAYER_INVULNERABLE_TIME = 0.5f; + // ==================== 玩家参数 ==================== + /** 玩家移动速度(格/秒) */ + public static final float PLAYER_SPEED = 5.0f; + /** 玩家最大生命值 */ + public static final float PLAYER_MAX_HEALTH = 100; + /** 玩家受伤后的无敌时间(秒),防止连续受伤 */ + public static final float PLAYER_INVULNERABLE_TIME = 0.5f; + /** 玩家死亡后的重生等待时间(秒) */ + public static final float PLAYER_RESPAWN_TIME = 30.0f; + + // ==================== 普通僵尸参数 ==================== + + /** 普通僵尸基础生命值 */ public static final float ZOMBIE_BASE_HEALTH = 100; + /** 普通僵尸基础移动速度(格/秒) */ public static final float ZOMBIE_BASE_SPEED = 2.0f; + /** 普通僵尸近战伤害 */ public static final float ZOMBIE_DAMAGE = 10; + /** 普通僵尸近战攻击间隔(秒) */ public static final float ZOMBIE_ATTACK_RATE = 1.0f; + /** 僵尸死亡后掉落物品的概率 */ public static final float ZOMBIE_LOOT_DROP_CHANCE = 0.3f; + /** 僵尸生成间隔基础值(秒),难度提升后会逐渐缩短 */ public static final float ZOMBIE_SPAWN_INTERVAL_BASE = 3.0f; - public static final float ZOMBIE_SPAWN_INTERVAL_MIN = 0.8f; + /** 僵尸生成间隔最小值(秒),防止生成过快 */ + public static final float ZOMBIE_SPAWN_INTERVAL_MIN = 0.5f; + /** 每次难度提升增加的僵尸生命值 */ public static final float ZOMBIE_HEALTH_INCREASE = 20; + /** 每次难度提升增加的僵尸速度 */ public static final float ZOMBIE_SPEED_INCREASE = 0.1f; + /** 难度提升的时间间隔(秒) */ public static final float DIFFICULTY_INCREASE_INTERVAL = 30.0f; + // ==================== 精英僵尸参数 ==================== + // 精英僵尸:血量高、可远程射击的强力僵尸 + + /** 精英僵尸生命值 */ public static final float ELITE_ZOMBIE_HEALTH = 800; + /** 精英僵尸移动速度(比普通僵尸稍慢) */ public static final float ELITE_ZOMBIE_SPEED = 1.5f; + /** 精英僵尸近战伤害 */ public static final float ELITE_ZOMBIE_DAMAGE = 20; + /** 精英僵尸远程攻击范围(格) */ public static final float ELITE_ZOMBIE_ATTACK_RANGE = 8.0f; + /** 精英僵尸远程攻击间隔(秒) */ public static final float ELITE_ZOMBIE_ATTACK_RATE = 2.0f; + /** 精英僵尸远程子弹伤害 */ public static final int ELITE_ZOMBIE_BULLET_DAMAGE = 30; + /** 精英僵尸远程子弹飞行速度 */ public static final float ELITE_ZOMBIE_BULLET_SPEED = 6.0f; + /** 精英僵尸的生成概率(5%) */ public static final float ELITE_ZOMBIE_SPAWN_CHANCE = 0.05f; + // ==================== 分裂僵尸参数 ==================== + // 分裂僵尸:被击杀后分裂成多个普通小僵尸 + + /** 分裂僵尸生命值(较低,但击杀后会分裂) */ + public static final float SPLITTER_ZOMBIE_HEALTH = 50; + /** 分裂僵尸移动速度(比普通僵尸快) */ + public static final float SPLITTER_ZOMBIE_SPEED = 2.5f; + /** 分裂僵尸死亡后最少分裂数量 */ + public static final int SPLITTER_ZOMBIE_MIN_SPLIT = 2; + /** 分裂僵尸死亡后最多分裂数量 */ + public static final int SPLITTER_ZOMBIE_MAX_SPLIT = 6; + /** 分裂僵尸的生成概率(5%) */ + public static final float SPLITTER_ZOMBIE_SPAWN_CHANCE = 0.05f; + + // ==================== 武器类型 ==================== + + /** 手枪(初始武器,弹药无限) */ public static final String WEAPON_PISTOL = "pistol"; + /** 机枪(高射速,弹药有限) */ public static final String WEAPON_MACHINE_GUN = "machine_gun"; + /** 霰弹枪(散射多弹丸,近距离高伤害) */ public static final String WEAPON_SHOTGUN = "shotgun"; + /** 手榴弹(可蓄力投掷,爆炸范围伤害) */ public static final String WEAPON_GRENADE = "grenade"; + // ==================== 掉落物类型 ==================== + + /** 弹药补给 */ public static final String LOOT_TYPE_AMMO = "ammo"; + /** 医疗包 */ public static final String LOOT_TYPE_HEALTH = "health"; + /** 医疗包恢复的生命值 */ public static final float LOOT_HEALTH_AMOUNT = 30; + // ==================== 网络消息类型 ==================== + // 客户端与服务器之间通信的消息类型标识 + + /** 创建房间 */ public static final String MSG_CREATE_ROOM = "create_room"; + /** 加入房间 */ public static final String MSG_JOIN_ROOM = "join_room"; + /** 离开房间 */ public static final String MSG_LEAVE_ROOM = "leave_room"; + /** 房间列表 */ public static final String MSG_ROOM_LIST = "room_list"; + /** 房间状态更新 */ public static final String MSG_ROOM_STATE = "room_state"; + /** 玩家准备/取消准备 */ public static final String MSG_READY = "ready"; + /** 开始游戏 */ public static final String MSG_START_GAME = "start_game"; + /** 游戏已开始(服务器→客户端) */ public static final String MSG_GAME_STARTED = "game_started"; + /** 玩家输入(移动、射击等操作) */ public static final String MSG_PLAYER_INPUT = "player_input"; + /** 游戏状态同步(服务器→客户端,每 tick 发送) */ public static final String MSG_GAME_STATE = "game_state"; + /** 玩家加入通知 */ public static final String MSG_PLAYER_JOIN = "player_join"; + /** 玩家离开通知 */ public static final String MSG_PLAYER_LEAVE = "player_leave"; + /** 错误消息 */ public static final String MSG_ERROR = "error"; } diff --git a/backend/src/main/java/com/zombie/game/model/FlowField.java b/backend/src/main/java/com/zombie/game/model/FlowField.java new file mode 100644 index 0000000..a716026 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/FlowField.java @@ -0,0 +1,230 @@ +package com.zombie.game.model; + +import java.util.*; + +/** + * 流场导航类 + * + * 基于 BFS 算法计算距离场和流场,用于僵尸寻路。 + * 支持加权障碍物(如坚果墙体),僵尸可自动权衡绕道 vs 摧毁。 + */ +public class FlowField { + /** 地图宽度 */ + private final int width; + /** 地图高度 */ + private final int height; + /** 距离场:记录每个格子到最近玩家的距离 */ + private float[][] distanceField; + /** 流场X方向:僵尸移动的X方向分量 */ + private float[][] flowFieldX; + /** 流场Y方向:僵尸移动的Y方向分量 */ + private float[][] flowFieldY; + /** 流场是否有效 */ + private boolean valid; + + public FlowField(int width, int height) { + this.width = width; + this.height = height; + this.distanceField = new float[height][width]; + this.flowFieldX = new float[height][width]; + this.flowFieldY = new float[height][width]; + this.valid = false; + } + + /** + * 更新流场导航 + * + * 基于玩家位置计算流场,使用 Dijkstra 算法(支持加权格子)。 + * + * @param walls 墙体集合,用于查询格子通行代价 + * @param playerPositions 玩家位置列表 + */ + public void update(Map walls, List playerPositions) { + // 初始化距离场 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + distanceField[y][x] = Float.MAX_VALUE; + } + } + + // 优先队列实现 Dijkstra + PriorityQueue queue = new PriorityQueue<>(Comparator.comparingDouble(n -> n.dist)); + + for (float[] pos : playerPositions) { + int px = (int) Math.floor(pos[0]); + int py = (int) Math.floor(pos[1]); + if (px >= 0 && px < width && py >= 0 && py < height) { + String key = key(px, py); + Wall wall = walls.get(key); + if (wall == null || !wall.blocksMovement()) { + distanceField[py][px] = 0; + queue.add(new Node(px, py, 0)); + } + } + } + + int[][] dirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}}; + + while (!queue.isEmpty()) { + Node current = queue.poll(); + int cx = current.x; + int cy = current.y; + float currentDist = current.dist; + + if (currentDist > distanceField[cy][cx]) continue; + + for (int[] dir : dirs) { + int nx = cx + dir[0]; + int ny = cy + dir[1]; + + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + + if (dir[0] != 0 && dir[1] != 0) { + if (isBlocked(walls, cx + dir[0], cy) || isBlocked(walls, cx, cy + dir[1])) { + continue; + } + } + + float moveCost = getMovementCost(walls, nx, ny, dir); + if (moveCost == Float.MAX_VALUE) continue; + + float newDist = currentDist + moveCost; + + if (newDist < distanceField[ny][nx]) { + distanceField[ny][nx] = newDist; + queue.add(new Node(nx, ny, newDist)); + } + } + } + + // 计算流场方向 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (isBlocked(walls, x, y) || distanceField[y][x] == Float.MAX_VALUE) { + flowFieldX[y][x] = 0; + flowFieldY[y][x] = 0; + continue; + } + + float bestDist = distanceField[y][x]; + float bestDirX = 0; + float bestDirY = 0; + + for (int[] dir : dirs) { + int nx = x + dir[0]; + int ny = y + dir[1]; + + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + if (isBlocked(walls, nx, ny)) continue; + + if (dir[0] != 0 && dir[1] != 0) { + if (isBlocked(walls, x + dir[0], y) || isBlocked(walls, x, y + dir[1])) { + continue; + } + } + + if (distanceField[ny][nx] < bestDist) { + bestDist = distanceField[ny][nx]; + float len = (float) Math.sqrt(dir[0] * dir[0] + dir[1] * dir[1]); + bestDirX = dir[0] / len; + bestDirY = dir[1] / len; + } + } + + flowFieldX[y][x] = bestDirX; + flowFieldY[y][x] = bestDirY; + } + } + + valid = true; + } + + /** + * 获取指定位置的流场方向 + * + * @param wx 世界X坐标 + * @param wy 世界Y坐标 + * @return 流场方向向量 [dx, dy] + */ + public float[] getDirection(float wx, float wy) { + int gx = (int) Math.floor(wx); + int gy = (int) Math.floor(wy); + + if (gx < 0 || gx >= width || gy < 0 || gy >= height) { + return new float[]{0, 0}; + } + + return new float[]{flowFieldX[gy][gx], flowFieldY[gy][gx]}; + } + + /** + * 获取指定位置到最近玩家的距离 + * + * @param wx 世界X坐标 + * @param wy 世界Y坐标 + * @return 距离值 + */ + public float getDistance(float wx, float wy) { + int gx = (int) Math.floor(wx); + int gy = (int) Math.floor(wy); + + if (gx < 0 || gx >= width || gy < 0 || gy >= height) { + return Float.MAX_VALUE; + } + + return distanceField[gy][gx]; + } + + /** 检查流场是否有效 */ + public boolean isValid() { + return valid; + } + + /** 标记流场为无效(当障碍物变化时调用) */ + public void invalidate() { + valid = false; + } + + /** + * 检查格子是否被阻挡 + */ + private boolean isBlocked(Map walls, int gx, int gy) { + if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true; + Wall wall = walls.get(key(gx, gy)); + return wall != null && wall.blocksMovement(); + } + + /** + * 获取格子的移动代价 + */ + private float getMovementCost(Map walls, int gx, int gy, int[] dir) { + Wall wall = walls.get(key(gx, gy)); + float baseCost = (dir[0] != 0 && dir[1] != 0) ? 1.414f : 1.0f; + if (wall == null || wall.isDestroyed()) { + return baseCost; + } + float wallCost = wall.getMovementCost(); + if (wallCost == Float.MAX_VALUE) { + return Float.MAX_VALUE; + } + return baseCost + wallCost - 1.0f; // wallCost 已包含基础代价 + } + + private static String key(int x, int y) { + return x + "," + y; + } + + /** + * Dijkstra 节点 + */ + private static class Node { + int x, y; + float dist; + + Node(int x, int y, float dist) { + this.x = x; + this.y = y; + this.dist = dist; + } + } +} diff --git a/backend/src/main/java/com/zombie/game/model/GameMap.java b/backend/src/main/java/com/zombie/game/model/GameMap.java index 50c9832..d58273f 100644 --- a/backend/src/main/java/com/zombie/game/model/GameMap.java +++ b/backend/src/main/java/com/zombie/game/model/GameMap.java @@ -1,32 +1,52 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 游戏地图类 + * + * 管理游戏地图数据,包括: + * - 地图格子(玩家出生点、僵尸出生点) + * - 墙体管理(StaticWall、NutWall) + * - A* 寻路算法 + */ +@Getter public class GameMap { + /** 地图格子数据:0=空地, 2=玩家出生点, 3=僵尸出生点 */ private final int[][] cells; + /** 地图宽度 */ private final int width; + /** 地图高度 */ private final int height; - private float[][] distanceField; - private float[][] flowFieldX; - private float[][] flowFieldY; - private boolean flowFieldValid; + /** 墙体集合:key="x,y" */ + private final Map walls; + /** 流场导航 */ + private final FlowField flowField; + /** + * 构造函数 - 初始化地图并生成默认布局 + */ public GameMap() { this.width = Constants.GRID_SIZE; this.height = Constants.GRID_SIZE; this.cells = new int[height][width]; - this.distanceField = new float[height][width]; - this.flowFieldX = new float[height][width]; - this.flowFieldY = new float[height][width]; - this.flowFieldValid = false; + this.walls = new HashMap<>(); + this.flowField = new FlowField(width, height); generateDefaultMap(); } + /** + * 生成默认地图布局 + * + * 创建边界墙壁、内部障碍物、玩家出生点和僵尸出生点 + */ private void generateDefaultMap() { for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { if (x == 0 || x == width - 1 || y == 0 || y == height - 1) { - cells[y][x] = 1; + cells[y][x] = 0; + walls.put(key(x, y), new StaticWall(x, y)); } else { cells[y][x] = 0; } @@ -45,13 +65,13 @@ public class GameMap { if (seg[0] == seg[2]) { for (int y = seg[1]; y <= seg[3]; y++) { if (seg[0] > 0 && seg[0] < width - 1 && y > 0 && y < height - 1) { - cells[y][seg[0]] = 1; + walls.put(key(seg[0], y), new StaticWall(seg[0], y)); } } } else { for (int x = seg[0]; x <= seg[2]; x++) { if (x > 0 && x < width - 1 && seg[1] > 0 && seg[1] < height - 1) { - cells[seg[1]][x] = 1; + walls.put(key(x, seg[1]), new StaticWall(x, seg[1])); } } } @@ -64,7 +84,7 @@ public class GameMap { for (int dx = -1; dx <= 1; dx++) { int ny = sp[1] + dy, nx = sp[0] + dx; if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1) { - if (cells[ny][nx] == 1) cells[ny][nx] = 0; + walls.remove(key(nx, ny)); } } } @@ -76,7 +96,7 @@ public class GameMap { for (int dx = -1; dx <= 1; dx++) { int ny = sp[1] + dy, nx = sp[0] + dx; if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1) { - if (cells[ny][nx] == 1) cells[ny][nx] = 0; + walls.remove(key(nx, ny)); } } } @@ -89,18 +109,84 @@ public class GameMap { for (int dx = -1; dx <= 1; dx++) { int ny = sp[1] + dy, nx = sp[0] + dx; if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1) { - if (cells[ny][nx] == 1) cells[ny][nx] = 0; + walls.remove(key(nx, ny)); } } } } } - public boolean isWall(int gx, int gy) { - if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true; - return cells[gy][gx] == 1; + /** + * 添加坚果墙体 + * + * @param gx 格子X坐标 + * @param gy 格子Y坐标 + * @return true 表示添加成功 + */ + public boolean addNutWall(int gx, int gy) { + if (gx < 0 || gx >= width || gy < 0 || gy >= height) return false; + if (cells[gy][gx] != 0) return false; + if (walls.containsKey(key(gx, gy))) return false; + walls.put(key(gx, gy), new NutWall(gx, gy)); + flowField.invalidate(); + return true; } + /** + * 移除墙体(当坚果被破坏时调用) + * + * @param gx 格子X坐标 + * @param gy 格子Y坐标 + */ + public void removeWall(int gx, int gy) { + walls.remove(key(gx, gy)); + flowField.invalidate(); + } + + /** + * 获取指定格子的墙体 + * + * @param gx 格子X坐标 + * @param gy 格子Y坐标 + * @return 墙体对象,如果没有则返回 null + */ + public Wall getWall(int gx, int gy) { + return walls.get(key(gx, gy)); + } + + /** + * 检查指定格子是否为墙壁(静态或坚果) + * + * @param gx 格子X坐标 + * @param gy 格子Y坐标 + * @return true 表示是墙壁或越界 + */ + public boolean isWall(int gx, int gy) { + if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true; + Wall wall = walls.get(key(gx, gy)); + return wall != null && wall.blocksMovement(); + } + + /** + * 检查指定格子是否有可破坏的坚果墙体 + * + * @param gx 格子X坐标 + * @param gy 格子Y坐标 + * @return true 表示有未破坏的坚果墙体 + */ + public boolean isNutWall(int gx, int gy) { + Wall wall = getWall(gx, gy); + return wall instanceof NutWall && !wall.isDestroyed(); + } + + /** + * 检查指定世界坐标是否可通行 + * + * @param wx 世界X坐标 + * @param wy 世界Y坐标 + * @param size 实体碰撞体大小 + * @return true 表示可通行 + */ public boolean isWalkable(float wx, float wy, float size) { float half = size / 2; float[][] corners = { @@ -119,6 +205,11 @@ public class GameMap { return cells; } + /** + * 获取所有玩家出生点 + * + * @return 出生点坐标列表 + */ public List getSpawnPoints() { List points = new ArrayList<>(); for (int y = 0; y < height; y++) { @@ -131,6 +222,11 @@ public class GameMap { return points; } + /** + * 获取所有僵尸出生点 + * + * @return 僵尸出生点坐标列表 + */ public List getZombieSpawnPoints() { List points = new ArrayList<>(); int[][] zombieSpawns = {{8, 8}, {24, 24}}; @@ -140,193 +236,47 @@ public class GameMap { return points; } + /** + * 更新流场导航 + * + * @param playerPositions 玩家位置列表 + */ public void updateFlowField(List playerPositions) { - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - distanceField[y][x] = Float.MAX_VALUE; - } - } - - Queue queue = new LinkedList<>(); - for (float[] pos : playerPositions) { - int px = (int) Math.floor(pos[0]); - int py = (int) Math.floor(pos[1]); - if (px >= 0 && px < width && py >= 0 && py < height && !isWall(px, py)) { - distanceField[py][px] = 0; - queue.add(new int[]{px, py}); - } - } - - int[][] dirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}}; - - while (!queue.isEmpty()) { - int[] current = queue.poll(); - int cx = current[0]; - int cy = current[1]; - float currentDist = distanceField[cy][cx]; - - for (int[] dir : dirs) { - int nx = cx + dir[0]; - int ny = cy + dir[1]; - - if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - if (isWall(nx, ny)) continue; - - if (dir[0] != 0 && dir[1] != 0) { - if (isWall(cx + dir[0], cy) || isWall(cx, cy + dir[1])) { - continue; - } - } - - float moveCost = (dir[0] != 0 && dir[1] != 0) ? 1.414f : 1.0f; - float newDist = currentDist + moveCost; - - if (newDist < distanceField[ny][nx]) { - distanceField[ny][nx] = newDist; - queue.add(new int[]{nx, ny}); - } - } - } - - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x++) { - if (isWall(x, y) || distanceField[y][x] == Float.MAX_VALUE) { - flowFieldX[y][x] = 0; - flowFieldY[y][x] = 0; - continue; - } - - float bestDist = distanceField[y][x]; - float bestDirX = 0; - float bestDirY = 0; - - for (int[] dir : dirs) { - int nx = x + dir[0]; - int ny = y + dir[1]; - - if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - if (isWall(nx, ny)) continue; - - if (dir[0] != 0 && dir[1] != 0) { - if (isWall(x + dir[0], y) || isWall(x, y + dir[1])) { - continue; - } - } - - if (distanceField[ny][nx] < bestDist) { - bestDist = distanceField[ny][nx]; - float len = (float) Math.sqrt(dir[0] * dir[0] + dir[1] * dir[1]); - bestDirX = dir[0] / len; - bestDirY = dir[1] / len; - } - } - - flowFieldX[y][x] = bestDirX; - flowFieldY[y][x] = bestDirY; - } - } - - flowFieldValid = true; + flowField.update(walls, playerPositions); } + /** + * 获取指定位置的流场方向 + * + * @param wx 世界X坐标 + * @param wy 世界Y坐标 + * @return 流场方向向量 [dx, dy] + */ public float[] getFlowDirection(float wx, float wy) { - int gx = (int) Math.floor(wx); - int gy = (int) Math.floor(wy); - - if (gx < 0 || gx >= width || gy < 0 || gy >= height) { - return new float[]{0, 0}; - } - - return new float[]{flowFieldX[gy][gx], flowFieldY[gy][gx]}; + return flowField.getDirection(wx, wy); } + /** 检查流场是否有效 */ public boolean isFlowFieldValid() { - return flowFieldValid; + return flowField.isValid(); } - public float getDistance(float wx, float wy) { - int gx = (int) Math.floor(wx); - int gy = (int) Math.floor(wy); - - if (gx < 0 || gx >= width || gy < 0 || gy >= height) { - return Float.MAX_VALUE; - } - - return distanceField[gy][gx]; - } - - public List findPath(float startX, float startY, float endX, float endY) { - int sgx = (int) Math.floor(startX); - int sgy = (int) Math.floor(startY); - int egx = (int) Math.floor(endX); - int egy = (int) Math.floor(endY); - - if (isWall(egx, egy)) return null; - - PriorityQueue openSet = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f)); - Set closedSet = new HashSet<>(); - Map cameFrom = new HashMap<>(); - - String startKey = sgx + "," + sgy; - double h = Math.abs(sgx - egx) + Math.abs(sgy - egy); - openSet.add(new PathNode(sgx, sgy, 0, h)); - - int[][] dirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}}; - int iterations = 0; - - while (!openSet.isEmpty() && iterations < 2000) { - iterations++; - PathNode current = openSet.poll(); - String currentKey = current.x + "," + current.y; - - if (current.x == egx && current.y == egy) { - List path = new ArrayList<>(); - String key = currentKey; - while (cameFrom.containsKey(key)) { - String[] parts = key.split(","); - int cx = Integer.parseInt(parts[0]); - int cy = Integer.parseInt(parts[1]); - path.add(0, new float[]{cx + 0.5f, cy + 0.5f}); - key = cameFrom.get(key); - } - return path; - } - - closedSet.add(currentKey); - - for (int[] dir : dirs) { - int nx = current.x + dir[0]; - int ny = current.y + dir[1]; - String nKey = nx + "," + ny; - - if (closedSet.contains(nKey)) continue; - if (isWall(nx, ny)) continue; - if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; - - if (dir[0] != 0 && dir[1] != 0) { - if (isWall(current.x + dir[0], current.y) || isWall(current.x, current.y + dir[1])) { - continue; - } - } - - boolean diagonal = dir[0] != 0 && dir[1] != 0; - double moveCost = diagonal ? 1.414 : 1.0; - double g = current.g + moveCost; - - double nh = Math.abs(nx - egx) + Math.abs(ny - egy); - openSet.add(new PathNode(nx, ny, g, nh)); - if (!cameFrom.containsKey(nKey)) { - cameFrom.put(nKey, currentKey); - } - } - } - - return null; + private static String key(int x, int y) { + return x + "," + y; } + /** + * A* 寻路算法的节点类 + */ private static class PathNode { + /** 节点格子坐标 */ int x, y; - double g, h, f; + /** 从起点到当前节点的实际代价 */ + double g; + /** 从当前节点到终点的启发式代价 */ + double h; + /** 总代价 f = g + h */ + double f; PathNode(int x, int y, double g, double h) { this.x = x; diff --git a/backend/src/main/java/com/zombie/game/model/GameWorld.java b/backend/src/main/java/com/zombie/game/model/GameWorld.java index 0458416..d98a17d 100644 --- a/backend/src/main/java/com/zombie/game/model/GameWorld.java +++ b/backend/src/main/java/com/zombie/game/model/GameWorld.java @@ -1,8 +1,20 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 游戏世界类 + * + * 管理整个游戏状态,包括: + * - 玩家、僵尸、子弹、掉落物等实体 + * - 游戏时间、波数、分数 + * - 难度递增系统 + * - 碰撞检测和游戏逻辑更新 + */ +@Getter public class GameWorld { + private final Object lock = new Object(); private GameMap map; private Map players; private Map zombies; @@ -51,8 +63,11 @@ public class GameWorld { this.removedZombieBullets = new ArrayList<>(); } - public GameMap getMap() { return map; } - + /** + * 添加玩家到游戏世界 + * + * @param player 玩家对象 + */ public void addPlayer(Player player) { List spawnPoints = map.getSpawnPoints(); int idx = players.size() % spawnPoints.size(); @@ -63,16 +78,38 @@ public class GameWorld { players.put(player.getId(), player); } + /** + * 从游戏世界移除玩家 + * + * @param playerId 玩家ID + */ public void removePlayer(String playerId) { players.remove(playerId); } + /** 获取指定ID的玩家 */ public Player getPlayer(String id) { return players.get(id); } + /** 获取所有玩家 */ public Collection getPlayers() { return players.values(); } + /** 获取所有僵尸 */ public Collection getZombies() { return zombies.values(); } + /** 获取所有玩家子弹 */ public Collection getBullets() { return bullets.values(); } + /** 获取所有掉落物 */ public Collection getLoots() { return loots.values(); } + /** + * 更新游戏世界状态 + * + * 每帧调用,处理: + * - 时间流逝和难度提升 + * - 僵尸生成 + * - 实体移动和碰撞 + * - 掉落物收集 + * - 玩家重生 + * + * @param dt 时间增量(秒) + */ public void update(float dt) { explosions.clear(); removedBullets.clear(); @@ -103,8 +140,14 @@ public class GameWorld { checkZombieBulletCollisions(); checkZombieAttacks(); checkLootCollection(); + checkPlayerRespawn(); } + /** + * 更新流场导航 + * + * 基于存活玩家的位置更新流场 + */ private void updateFlowField() { List playerPositions = new ArrayList<>(); for (Player p : players.values()) { @@ -117,22 +160,36 @@ public class GameWorld { } } + /** + * 生成僵尸 + * + * 根据概率生成普通僵尸、精英僵尸或分裂僵尸 + */ private void spawnZombie() { List spawnPoints = map.getZombieSpawnPoints(); int[] sp = spawnPoints.get(random.nextInt(spawnPoints.size())); float wx = sp[0] + 0.5f; float wy = sp[1] + 0.5f; - boolean isElite = random.nextFloat() < Constants.ELITE_ZOMBIE_SPAWN_CHANCE; + float roll = random.nextFloat(); Zombie zombie; - if (isElite) { - zombie = new Zombie(nextZombieId++, wx, wy, Constants.ELITE_ZOMBIE_HEALTH, Constants.ELITE_ZOMBIE_SPEED, true); + if (roll < Constants.ELITE_ZOMBIE_SPAWN_CHANCE) { + zombie = new Zombie(nextZombieId++, wx, wy, Constants.ELITE_ZOMBIE_HEALTH, Constants.ELITE_ZOMBIE_SPEED, true, false); + } else if (roll < Constants.ELITE_ZOMBIE_SPAWN_CHANCE + Constants.SPLITTER_ZOMBIE_SPAWN_CHANCE) { + zombie = new Zombie(nextZombieId++, wx, wy, Constants.SPLITTER_ZOMBIE_HEALTH, Constants.SPLITTER_ZOMBIE_SPEED, false, true); } else { - zombie = new Zombie(nextZombieId++, wx, wy, zombieHealth, zombieSpeed, false); + zombie = new Zombie(nextZombieId++, wx, wy, zombieHealth, zombieSpeed, false, false); } zombies.put(zombie.getId(), zombie); } + /** + * 查找最近的存活玩家 + * + * @param x X坐标 + * @param y Y坐标 + * @return 最近的玩家,如果没有存活玩家则返回 null + */ private Player findNearestPlayer(float x, float y) { Player nearest = null; float minDist = Float.MAX_VALUE; @@ -147,6 +204,13 @@ public class GameWorld { return nearest; } + /** + * 更新所有僵尸 + * + * 处理僵尸移动、攻击和死亡 + * + * @param dt 时间增量(秒) + */ private void updateZombies(float dt) { long now = System.currentTimeMillis(); @@ -175,6 +239,12 @@ public class GameWorld { } } + /** + * 精英僵尸发射子弹 + * + * @param zombie 发射子弹的僵尸 + * @param target 目标玩家 + */ private void fireZombieBullet(Zombie zombie, Player target) { float dx = target.getX() - zombie.getX(); float dy = target.getY() - zombie.getY(); @@ -189,6 +259,11 @@ public class GameWorld { zombieBullets.put(bullet.getId(), bullet); } + /** + * 更新所有僵尸子弹 + * + * @param dt 时间增量(秒) + */ private void updateZombieBullets(float dt) { List toRemove = new ArrayList<>(); for (Bullet b : zombieBullets.values()) { @@ -202,6 +277,9 @@ public class GameWorld { } } + /** + * 检测僵尸子弹与玩家的碰撞 + */ private void checkZombieBulletCollisions() { List bulletsToRemove = new ArrayList<>(); for (Bullet b : new ArrayList<>(zombieBullets.values())) { @@ -220,8 +298,31 @@ public class GameWorld { } } + /** + * 处理僵尸死亡 + * + * - 分裂僵尸分裂成多个小僵尸 + * - 增加分数 + * - 可能掉落物品 + * + * @param z 被击杀的僵尸 + */ private void onZombieKilled(Zombie z) { - score += z.isElite() ? 50 : 10; + if (z.isSplitter()) { + int splitCount = Constants.SPLITTER_ZOMBIE_MIN_SPLIT + + random.nextInt(Constants.SPLITTER_ZOMBIE_MAX_SPLIT - Constants.SPLITTER_ZOMBIE_MIN_SPLIT + 1); + for (int i = 0; i < splitCount; i++) { + float offsetX = (random.nextFloat() - 0.5f) * 1.0f; + float offsetY = (random.nextFloat() - 0.5f) * 1.0f; + Zombie splitZombie = new Zombie(nextZombieId++, + z.getX() + offsetX, z.getY() + offsetY, + zombieHealth, zombieSpeed, false, false); + zombies.put(splitZombie.getId(), splitZombie); + } + score += 20; + } else { + score += z.isElite() ? 50 : 10; + } if (random.nextFloat() < Constants.ZOMBIE_LOOT_DROP_CHANCE) { String lootType = random.nextFloat() < 0.5f ? Constants.LOOT_TYPE_AMMO : Constants.LOOT_TYPE_HEALTH; Loot loot = new Loot(nextLootId++, z.getX(), z.getY(), lootType); @@ -229,6 +330,11 @@ public class GameWorld { } } + /** + * 更新所有玩家子弹 + * + * @param dt 时间增量(秒) + */ private void updateBullets(float dt) { List toRemove = new ArrayList<>(); for (Bullet b : bullets.values()) { @@ -245,6 +351,9 @@ public class GameWorld { } } + /** + * 检测玩家子弹与僵尸的碰撞 + */ private void checkBulletCollisions() { List bulletsToRemove = new ArrayList<>(); for (Bullet b : new ArrayList<>(bullets.values())) { @@ -264,6 +373,16 @@ public class GameWorld { } } + /** + * 创建爆炸效果 + * + * 对范围内的僵尸造成伤害 + * + * @param x 爆炸中心X坐标 + * @param y 爆炸中心Y坐标 + * @param radius 爆炸半径 + * @param ownerId 爆炸发起者ID + */ private void createExplosion(float x, float y, float radius, String ownerId) { Map exp = new LinkedHashMap<>(); exp.put("x", x); @@ -279,6 +398,9 @@ public class GameWorld { } } + /** + * 检测僵尸近战攻击 + */ private void checkZombieAttacks() { long now = System.currentTimeMillis(); for (Zombie z : zombies.values()) { @@ -294,6 +416,9 @@ public class GameWorld { } } + /** + * 检测掉落物收集 + */ private void checkLootCollection() { List toRemove = new ArrayList<>(); for (Loot loot : loots.values()) { @@ -315,10 +440,53 @@ public class GameWorld { } } + /** + * 检测玩家重生 + */ + private void checkPlayerRespawn() { + boolean hasAlivePlayer = false; + for (Player p : players.values()) { + if (p.isAlive()) { + hasAlivePlayer = true; + break; + } + } + + for (Player p : players.values()) { + if (p.isWaitingForRespawn()) { + p.updateRespawnTimer(Constants.TICK_INTERVAL); + if (hasAlivePlayer && p.canRespawn()) { + List spawnPoints = map.getSpawnPoints(); + int[] sp = spawnPoints.get(random.nextInt(spawnPoints.size())); + float wx = sp[0] + 0.5f; + float wy = sp[1] + 0.5f; + p.respawn(wx, wy); + } + } + } + } + + /** + * 玩家开火(无蓄力) + * + * @param player 玩家 + * @param aimX 瞄准X坐标 + * @param aimY 瞄准Y坐标 + * @return 新创建的子弹ID列表 + */ public List fireWeapon(Player player, float aimX, float aimY) { return fireWeapon(player, aimX, aimY, 0); } + /** + * 玩家开火(支持蓄力) + * + * @param player 玩家 + * @param aimX 瞄准X坐标 + * @param aimY 瞄准Y坐标 + * @param chargePercent 蓄力百分比(0-1),影响手榴弹投掷距离 + * @return 新创建的子弹ID列表 + */ public List fireWeapon(Player player, float aimX, float aimY, float chargePercent) { List newBulletIds = new ArrayList<>(); long now = System.currentTimeMillis(); @@ -399,10 +567,19 @@ public class GameWorld { return newBulletIds; } + /** 获取地图数据 */ public int[][] getMapData() { return map.getCells(); } + /** + * 构建游戏状态数据 + * + * 将当前游戏世界的所有状态打包成Map格式,用于网络传输 + * + * @param forPlayerId 目标玩家ID(用于发送该玩家的弹药信息) + * @return 游戏状态Map + */ public Map buildGameState(String forPlayerId) { Map state = new LinkedHashMap<>(); diff --git a/backend/src/main/java/com/zombie/game/model/Loot.java b/backend/src/main/java/com/zombie/game/model/Loot.java index feaf7ba..352e554 100644 --- a/backend/src/main/java/com/zombie/game/model/Loot.java +++ b/backend/src/main/java/com/zombie/game/model/Loot.java @@ -1,12 +1,19 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 掉落物类 + * + * 管理僵尸死亡后掉落的物品,包括弹药和生命值补给。 + */ +@Getter public class Loot { - private int id; - private float x, y; - private String type; - private long spawnTime; + private final int id; + private final float x, y; + private final String type; + private final long spawnTime; public Loot(int id, float x, float y) { this(id, x, y, Constants.LOOT_TYPE_AMMO); @@ -20,11 +27,6 @@ public class Loot { this.spawnTime = System.currentTimeMillis(); } - public int getId() { return id; } - public float getX() { return x; } - public float getY() { return y; } - public String getType() { return type; } - public boolean isCollectedBy(float px, float py) { float dx = px - x; float dy = py - y; diff --git a/backend/src/main/java/com/zombie/game/model/NutWall.java b/backend/src/main/java/com/zombie/game/model/NutWall.java new file mode 100644 index 0000000..50f1afd --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/NutWall.java @@ -0,0 +1,60 @@ +package com.zombie.game.model; + +import lombok.Getter; + +/** + * 坚果墙体 + * + * 可被僵尸攻击破坏的障碍物。 + * 设计血量:僵尸移动速度 2.0格/秒,攻击间隔 1.0秒, + * 假设僵尸每攻击一次造成 1 点伤害(用于破坏墙体), + * 需要 20 个时间单位(约 20 秒)才能破坏。 + * + * 流场中,坚果的移动代价 = 基础代价 + 摧毁代价(20.0), + * 这样 BFS 会自动权衡绕道 vs 摧毁。 + */ +public class NutWall extends Wall { + /** 坚果最大血量(20个时间单位) */ + public static final float MAX_HEALTH = 20.0f; + /** 坚果在流场中的额外移动代价(摧毁时间折算) */ + public static final float MOVEMENT_COST_PENALTY = 20.0f; + /** 当前血量 */ + @Getter + private float health; + /** 是否已被破坏 */ + private boolean destroyed; + + public NutWall(int gridX, int gridY) { + super(gridX, gridY); + this.health = MAX_HEALTH; + this.destroyed = false; + } + + @Override + public boolean isDestructible() { + return true; + } + + @Override + public boolean isDestroyed() { + return destroyed; + } + + @Override + public void takeDamage(float damage) { + if (destroyed) return; + this.health -= damage; + if (this.health <= 0) { + this.health = 0; + this.destroyed = true; + } + } + + @Override + public float getMovementCost() { + if (destroyed) { + return 1.0f; // 被破坏后视为空地 + } + return 1.0f + MOVEMENT_COST_PENALTY; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Player.java b/backend/src/main/java/com/zombie/game/model/Player.java index 7263209..2818ac9 100644 --- a/backend/src/main/java/com/zombie/game/model/Player.java +++ b/backend/src/main/java/com/zombie/game/model/Player.java @@ -1,7 +1,18 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 玩家类 + * + * 管理玩家状态,包括: + * - 位置、朝向、生命值 + * - 武器和弹药 + * - 移动和射击 + * - 重生机制 + */ +@Getter public class Player { private String id; private String name; @@ -17,6 +28,8 @@ public class Player { private float grenadeChargeStart; private boolean chargingGrenade; private int lastProcessedSeq; + private float respawnTimer; + private boolean waitingForRespawn; private static final String[] WEAPONS = { Constants.WEAPON_PISTOL, Constants.WEAPON_MACHINE_GUN, @@ -41,23 +54,31 @@ public class Player { this.grenadeChargeStart = 0; this.chargingGrenade = false; this.lastProcessedSeq = 0; + this.respawnTimer = 0; + this.waitingForRespawn = false; } - public String getId() { return id; } - public String getName() { return name; } - public float getX() { return x; } - public float getY() { return y; } - public float getAngle() { return angle; } - public float getHealth() { return health; } - public int getWeaponIndex() { return weaponIndex; } - public boolean isReady() { return ready; } - public float[] getAmmo() { return ammo; } - public boolean isFiring() { return firing; } - public int getLastProcessedSeq() { return lastProcessedSeq; } - public void setReady(boolean ready) { this.ready = ready; } public void setWeaponIndex(int idx) { this.weaponIndex = Math.max(0, Math.min(3, idx)); } + /** + * 设置玩家位置 + * + * @param x 新位置X坐标 + * @param y 新位置Y坐标 + */ + public void setPosition(float x, float y) { + this.x = x; + this.y = y; + } + + /** + * 应用移动输入 + * + * @param dx X方向移动量 + * @param dy Y方向移动量 + * @param map 游戏地图(用于碰撞检测) + */ public void applyMovement(float dx, float dy, GameMap map) { float speed = Constants.PLAYER_SPEED * Constants.TICK_INTERVAL; float newX = x + dx * speed; @@ -71,34 +92,114 @@ public class Player { } } + /** + * 设置朝向角度 + * + * @param aimX 瞄准点X坐标 + * @param aimY 瞄准点Y坐标 + */ public void setAngle(float aimX, float aimY) { this.angle = (float) Math.atan2(aimX - x, aimY - y); } + /** + * 受到伤害 + * + * @param damage 伤害值 + */ public void takeDamage(float damage) { long now = System.currentTimeMillis(); if (now - lastDamageTime < Constants.PLAYER_INVULNERABLE_TIME * 1000) return; this.health -= damage; this.lastDamageTime = now; if (this.health < 0) this.health = 0; + if (this.health <= 0) { + startRespawnTimer(); + } } + /** 开始重生倒计时 */ + public void startRespawnTimer() { + this.waitingForRespawn = true; + this.respawnTimer = Constants.PLAYER_RESPAWN_TIME; + } + + /** + * 更新重生倒计时 + * + * @param dt 时间增量(秒) + */ + public void updateRespawnTimer(float dt) { + if (waitingForRespawn && respawnTimer > 0) { + respawnTimer -= dt; + } + } + + /** 检查是否可以重生 */ + public boolean canRespawn() { + return waitingForRespawn && respawnTimer <= 0; + } + + /** + * 重生玩家 + * + * @param newX 新位置X坐标 + * @param newY 新位置Y坐标 + */ + public void respawn(float newX, float newY) { + this.health = Constants.PLAYER_MAX_HEALTH; + this.x = newX; + this.y = newY; + this.waitingForRespawn = false; + this.respawnTimer = 0; + this.lastDamageTime = System.currentTimeMillis(); + } + + /** 是否等待重生 */ + public boolean isWaitingForRespawn() { + return waitingForRespawn; + } + + /** 获取重生倒计时 */ + public float getRespawnTimer() { + return respawnTimer; + } + + /** 是否存活 */ public boolean isAlive() { return health > 0; } + /** + * 计算到指定点的距离 + * + * @param px 目标X坐标 + * @param py 目标Y坐标 + * @return 距离值 + */ public float distanceTo(float px, float py) { float dx = px - x; float dy = py - y; return (float) Math.sqrt(dx * dx + dy * dy); } + /** + * 检查是否可以开火 + * + * @param now 当前时间戳 + * @return true 表示可以开火 + */ public boolean canFire(long now) { String weapon = WEAPONS[weaponIndex]; long fireRate = getFireRate(weapon); return now - lastAttackTime >= fireRate; } + /** + * 执行开火动作 + * + * @param now 当前时间戳 + */ public void fire(long now) { lastAttackTime = now; String weapon = WEAPONS[weaponIndex]; @@ -108,24 +209,34 @@ public class Player { } } + /** 检查是否有弹药 */ public boolean hasAmmo() { if (weaponIndex == 0) return true; return ammo[weaponIndex] > 0; } + /** 随机补充一个武器的弹药 */ public void refillRandomWeapon() { Random rand = new Random(); int idx = rand.nextInt(3) + 1; ammo[idx] = MAX_AMMO[idx]; } + /** + * 治疗 + * + * @param amount 治疗量 + */ public void heal(float amount) { this.health = Math.min(Constants.PLAYER_MAX_HEALTH, this.health + amount); } + /** 设置开火状态 */ public void setFiring(boolean firing) { this.firing = firing; } + /** 设置最后处理的输入序列号 */ public void setLastProcessedSeq(int seq) { this.lastProcessedSeq = seq; } + /** 开始手榴弹蓄力 */ public void startGrenadeCharge() { if (!chargingGrenade) { chargingGrenade = true; @@ -133,16 +244,24 @@ public class Player { } } + /** 获取手榴弹蓄力百分比(0-1) */ public float getGrenadeChargePercent() { if (!chargingGrenade) return 0; float elapsed = (System.currentTimeMillis() - grenadeChargeStart) / 2000f; return Math.min(1.0f, elapsed); } + /** 停止手榴弹蓄力 */ public void stopGrenadeCharge() { chargingGrenade = false; } + /** + * 获取武器射速 + * + * @param weapon 武器类型 + * @return 射速(毫秒) + */ private long getFireRate(String weapon) { switch (weapon) { case Constants.WEAPON_PISTOL: return 400; @@ -153,6 +272,7 @@ public class Player { } } + /** 获取当前武器伤害 */ public int getDamage() { switch (WEAPONS[weaponIndex]) { case Constants.WEAPON_PISTOL: return 50; @@ -163,6 +283,7 @@ public class Player { } } + /** 获取当前武器子弹速度 */ public float getBulletSpeed() { switch (WEAPONS[weaponIndex]) { case Constants.WEAPON_PISTOL: return 20; @@ -173,10 +294,12 @@ public class Player { } } + /** 获取当前武器弹丸数量(霰弹枪发射多个弹丸) */ public int getPelletCount() { return WEAPONS[weaponIndex].equals(Constants.WEAPON_SHOTGUN) ? 10 : 1; } + /** 获取当前武器散射角度 */ public float getSpread() { switch (WEAPONS[weaponIndex]) { case Constants.WEAPON_MACHINE_GUN: return 0.05f; @@ -185,10 +308,16 @@ public class Player { } } + /** 当前武器是否可蓄力(手榴弹) */ public boolean isChargeable() { return WEAPONS[weaponIndex].equals(Constants.WEAPON_GRENADE); } + /** + * 将玩家状态转换为Map格式,用于网络传输 + * + * @return 包含玩家状态的Map + */ public Map toStateMap() { Map map = new LinkedHashMap<>(); map.put("id", id); @@ -198,6 +327,8 @@ public class Player { map.put("health", health); map.put("weaponIndex", weaponIndex); map.put("lastProcessedSeq", lastProcessedSeq); + map.put("waitingForRespawn", waitingForRespawn); + map.put("respawnTimer", respawnTimer); return map; } } diff --git a/backend/src/main/java/com/zombie/game/model/Room.java b/backend/src/main/java/com/zombie/game/model/Room.java index 0110729..b3127bc 100644 --- a/backend/src/main/java/com/zombie/game/model/Room.java +++ b/backend/src/main/java/com/zombie/game/model/Room.java @@ -1,13 +1,25 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 游戏房间类 + * + * 管理游戏房间状态,包括: + * - 房间ID和房主 + * - 房间内的玩家列表 + * - 游戏开始状态 + */ +@Getter public class Room { private String id; private String hostId; private Map players; private boolean gameStarted; - private int maxPlayers = 4; + private final int maxPlayers = 4; + + public void setGameStarted(boolean started) { this.gameStarted = started; } public Room(String id, String hostId, String hostName) { this.id = id; @@ -17,11 +29,6 @@ public class Room { players.put(hostId, host); } - public String getId() { return id; } - public String getHostId() { return hostId; } - public boolean isGameStarted() { return gameStarted; } - public void setGameStarted(boolean started) { this.gameStarted = started; } - public boolean addPlayer(String playerId, String playerName) { if (players.size() >= maxPlayers) return false; if (players.containsKey(playerId)) return false; @@ -57,6 +64,12 @@ public class Room { return true; } + /** + * 将房间状态转换为Map格式,用于网络传输 + * + * @param playerId 目标玩家ID + * @return 包含房间状态的Map + */ public Map toStateMap(String playerId) { Map map = new LinkedHashMap<>(); map.put("roomId", id); @@ -78,6 +91,11 @@ public class Room { return map; } + /** + * 将房间信息转换为房间列表格式,用于大厅显示 + * + * @return 包含房间信息的Map + */ public Map toRoomListMap() { Map map = new LinkedHashMap<>(); map.put("id", id); diff --git a/backend/src/main/java/com/zombie/game/model/StaticWall.java b/backend/src/main/java/com/zombie/game/model/StaticWall.java new file mode 100644 index 0000000..3794187 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/StaticWall.java @@ -0,0 +1,38 @@ +package com.zombie.game.model; + +/** + * 静态墙体 + * + * 不可破坏的永久性障碍物,如边界墙、建筑等。 + */ +public class StaticWall extends Wall { + + public StaticWall(int gridX, int gridY) { + super(gridX, gridY); + } + + @Override + public boolean isDestructible() { + return false; + } + + @Override + public boolean isDestroyed() { + return false; + } + + @Override + public float getHealth() { + return Float.MAX_VALUE; + } + + @Override + public void takeDamage(float damage) { + // 静态墙体不可破坏,忽略伤害 + } + + @Override + public float getMovementCost() { + return Float.MAX_VALUE; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Wall.java b/backend/src/main/java/com/zombie/game/model/Wall.java new file mode 100644 index 0000000..3457e65 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Wall.java @@ -0,0 +1,68 @@ +package com.zombie.game.model; + +import lombok.Getter; + +/** + * 墙体抽象基类 + * + * 地图中的障碍物,分为: + * - StaticWall:静态墙体,不可破坏 + * - NutWall:坚果墙体,可被僵尸攻击破坏,有血量 + */ +public abstract class Wall { + /** 格子X坐标 */ + @Getter + protected final int gridX; + /** 格子Y坐标 */ + @Getter + protected final int gridY; + + public Wall(int gridX, int gridY) { + this.gridX = gridX; + this.gridY = gridY; + } + + /** + * 是否可破坏 + * + * @return true 表示可以被破坏 + */ + public abstract boolean isDestructible(); + + /** + * 是否已被破坏(仅对可破坏墙体有效) + * + * @return true 表示已破坏 + */ + public abstract boolean isDestroyed(); + + /** + * 获取当前血量(仅对可破坏墙体有效) + * + * @return 当前血量 + */ + public abstract float getHealth(); + + /** + * 受到伤害 + * + * @param damage 伤害值 + */ + public abstract void takeDamage(float damage); + + /** + * 获取移动代价(用于流场计算) + * + * @return 移动代价,不可通行返回 Float.MAX_VALUE + */ + public abstract float getMovementCost(); + + /** + * 是否阻挡移动 + * + * @return true 表示阻挡 + */ + public boolean blocksMovement() { + return !isDestroyed(); + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Zombie.java b/backend/src/main/java/com/zombie/game/model/Zombie.java index d6bdf0d..92d025f 100644 --- a/backend/src/main/java/com/zombie/game/model/Zombie.java +++ b/backend/src/main/java/com/zombie/game/model/Zombie.java @@ -1,7 +1,18 @@ package com.zombie.game.model; +import lombok.Getter; import java.util.*; +/** + * 僵尸类 + * + * 管理僵尸状态和行为,包括: + * - 位置、朝向、生命值 + * - 移动和寻路(基于流场导航) + * - 近战和远程攻击 + * - 三种类型:普通僵尸、精英僵尸、分裂僵尸 + */ +@Getter public class Zombie { private int id; private float x, y; @@ -11,17 +22,25 @@ public class Zombie { private float speed; private long lastAttackTime; private boolean isElite; + private boolean isSplitter; private long lastRangedAttackTime; private float targetX, targetY; private boolean hasTarget; private int reservedGridX, reservedGridY; - private boolean hasReservation; + private boolean reservation; // 是否有预留格子 + private int attackingWallGridX = -1; // 正在攻击的坚果墙体格子X + private int attackingWallGridY = -1; // 正在攻击的坚果墙体格子Y + private boolean attackingWall; // 是否正在攻击墙体 public Zombie(int id, float x, float y, float health, float speed) { - this(id, x, y, health, speed, false); + this(id, x, y, health, speed, false, false); } public Zombie(int id, float x, float y, float health, float speed, boolean isElite) { + this(id, x, y, health, speed, isElite, false); + } + + public Zombie(int id, float x, float y, float health, float speed, boolean isElite, boolean isSplitter) { this.id = id; this.x = x; this.y = y; @@ -31,35 +50,46 @@ public class Zombie { this.speed = speed; this.lastAttackTime = 0; this.isElite = isElite; + this.isSplitter = isSplitter; this.lastRangedAttackTime = 0; this.targetX = 0; this.targetY = 0; this.hasTarget = false; this.reservedGridX = -1; this.reservedGridY = -1; - this.hasReservation = false; + this.reservation = false; + this.attackingWallGridX = -1; + this.attackingWallGridY = -1; + this.attackingWall = false; } - public int getId() { return id; } - public float getX() { return x; } - public float getY() { return y; } - public float getAngle() { return angle; } - public float getHealth() { return health; } - public float getMaxHealth() { return maxHealth; } - public boolean isElite() { return isElite; } - public int getReservedGridX() { return reservedGridX; } - public int getReservedGridY() { return reservedGridY; } - public boolean hasReservation() { return hasReservation; } - + /** + * 受到伤害 + * + * @param damage 伤害值 + */ public void takeDamage(float damage) { this.health -= damage; if (this.health < 0) this.health = 0; } + /** 是否存活 */ public boolean isAlive() { return health > 0; } + /** + * 移动僵尸 + * + * 基于流场导航移动,包含: + * - 路径规划 + * - 避免与其他僵尸重叠 + * - 墙壁碰撞检测 + * + * @param map 游戏地图 + * @param dt 时间增量(秒) + * @param otherZombies 其他僵尸集合 + */ public void move(GameMap map, float dt, Collection otherZombies) { if (!map.isFlowFieldValid()) return; @@ -101,7 +131,7 @@ public class Zombie { nextGridX = currentGridX; nextGridY = currentGridY + (int) Math.signum(dirY); } else { - hasReservation = false; + reservation = false; return; } } @@ -113,14 +143,14 @@ public class Zombie { nextGridX = altDirs[0]; nextGridY = altDirs[1]; } else { - hasReservation = false; + reservation = false; return; } } reservedGridX = nextGridX; reservedGridY = nextGridY; - hasReservation = true; + reservation = true; targetX = nextGridX + 0.5f; targetY = nextGridY + 0.5f; @@ -223,6 +253,9 @@ public class Zombie { } } + /** + * 检查指定格子是否被其他僵尸占用或预留 + */ private boolean isGridOccupiedOrReserved(int gridX, int gridY, Collection otherZombies) { for (Zombie other : otherZombies) { if (other.getId() == this.id) continue; @@ -235,13 +268,18 @@ public class Zombie { return true; } - if (other.hasReservation() && other.getReservedGridX() == gridX && other.getReservedGridY() == gridY) { + if (other.isReservation() && other.getReservedGridX() == gridX && other.getReservedGridY() == gridY) { return true; } } return false; } + /** + * 寻找替代移动方向 + * + * 当目标格子被占用时,寻找其他可行方向 + */ private int[] findAlternativeDirection(int currentGridX, int currentGridY, float dirX, float dirY, GameMap map, Collection otherZombies) { int[][] allDirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}}; @@ -274,29 +312,63 @@ public class Zombie { return new int[]{candidates.get(0)[0], candidates.get(0)[1]}; } + /** + * 检查是否可以近战攻击 + * + * @param now 当前时间戳 + * @return true 表示可以攻击 + */ public boolean canAttack(long now) { return now - lastAttackTime >= Constants.ZOMBIE_ATTACK_RATE * 1000; } + /** + * 执行近战攻击 + * + * @param now 当前时间戳 + */ public void attack(long now) { lastAttackTime = now; } + /** + * 检查是否可以远程攻击(精英僵尸专用) + * + * @param now 当前时间戳 + * @return true 表示可以攻击 + */ public boolean canRangedAttack(long now) { if (!isElite) return false; return now - lastRangedAttackTime >= Constants.ELITE_ZOMBIE_ATTACK_RATE * 1000; } + /** + * 执行远程攻击(精英僵尸专用) + * + * @param now 当前时间戳 + */ public void rangedAttack(long now) { lastRangedAttackTime = now; } + /** + * 计算到指定点的距离 + * + * @param px 目标X坐标 + * @param py 目标Y坐标 + * @return 距离值 + */ public float distanceTo(float px, float py) { float dx = px - x; float dy = py - y; return (float) Math.sqrt(dx * dx + dy * dy); } + /** + * 将僵尸状态转换为Map格式,用于网络传输 + * + * @return 包含僵尸状态的Map + */ public Map toStateMap() { Map map = new LinkedHashMap<>(); map.put("id", id); @@ -305,6 +377,7 @@ public class Zombie { map.put("angle", angle); map.put("health", health); map.put("isElite", isElite); + map.put("isSplitter", isSplitter); return map; } } diff --git a/backend/src/main/java/com/zombie/game/server/GameLoop.java b/backend/src/main/java/com/zombie/game/server/GameLoop.java index 2415cbc..7e1febd 100644 --- a/backend/src/main/java/com/zombie/game/server/GameLoop.java +++ b/backend/src/main/java/com/zombie/game/server/GameLoop.java @@ -2,45 +2,87 @@ package com.zombie.game.server; import com.zombie.game.model.GameWorld; -public class GameLoop implements Runnable { - private String roomId; - private GameWorld world; - private GameWebSocketServer server; - private volatile boolean running; - private static final int TICK_RATE = 30; - private static final long TICK_INTERVAL = 1000 / TICK_RATE; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; - public GameLoop(String roomId, GameWorld world, GameWebSocketServer server) { +/** + * 游戏循环类 + * + * 管理游戏主循环,以固定帧率更新游戏世界状态。 + * 使用 ScheduledExecutorService 实现精确的定时调度,避免忙等待。 + */ +public class GameLoop { + /** 房间ID */ + private String roomId; + /** 游戏世界实例 */ + private GameWorld world; + /** 游戏状态广播回调 */ + private GameService.GameStateBroadcast broadcaster; + /** 循环运行标志 */ + private volatile boolean running; + /** 逻辑帧率(每秒 tick 数) */ + private static final int TICK_RATE = 30; + /** 每次 tick 的时间间隔(毫秒) */ + private static final long TICK_INTERVAL_MS = 1000 / TICK_RATE; + /** 每次 tick 的时间间隔(秒,用于游戏逻辑计算) */ + private static final float TICK_INTERVAL_SEC = 1.0f / TICK_RATE; + /** 定时任务执行器 */ + private ScheduledExecutorService scheduler; + + /** + * 构造函数 + * + * @param roomId 房间ID + * @param world 游戏世界实例 + * @param broadcaster 游戏状态广播回调 + */ + public GameLoop(String roomId, GameWorld world, GameService.GameStateBroadcast broadcaster) { this.roomId = roomId; this.world = world; - this.server = server; + this.broadcaster = broadcaster; this.running = true; + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "GameLoop-" + roomId); + t.setDaemon(true); + return t; + }); } - @Override - public void run() { - long lastTime = System.currentTimeMillis(); + /** + * 启动游戏循环 + */ + public void start() { + scheduler.scheduleAtFixedRate(this::tick, 0, TICK_INTERVAL_MS, TimeUnit.MILLISECONDS); + } - while (running) { - long now = System.currentTimeMillis(); - long delta = now - lastTime; + /** + * 单次游戏逻辑更新 + */ + private void tick() { + if (!running) { + return; + } + synchronized (world.getLock()) { + world.update(TICK_INTERVAL_SEC); + } + broadcaster.broadcast(roomId, world); + } - if (delta >= TICK_INTERVAL) { - float dt = delta / 1000.0f; - world.update(dt); - server.broadcastGameState(roomId, world); - lastTime = now; - } else { - try { - Thread.sleep(1); - } catch (InterruptedException e) { - break; + /** + * 停止游戏循环 + */ + public void stop() { + running = false; + if (scheduler != null && !scheduler.isShutdown()) { + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); } + } catch (InterruptedException e) { + scheduler.shutdownNow(); } } } - - public void stop() { - running = false; - } } diff --git a/backend/src/main/java/com/zombie/game/server/GameService.java b/backend/src/main/java/com/zombie/game/server/GameService.java new file mode 100644 index 0000000..181a41f --- /dev/null +++ b/backend/src/main/java/com/zombie/game/server/GameService.java @@ -0,0 +1,179 @@ +package com.zombie.game.server; + +import com.zombie.game.model.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 游戏服务类 + * + * 负责游戏生命周期管理,包括: + * - 游戏启动和停止 + * - 游戏世界状态管理 + * - 玩家输入处理 + * - 游戏状态构建 + * + * 将游戏逻辑从 WebSocket 层解耦,便于独立测试和维护。 + */ +public class GameService { + private static final Logger logger = LoggerFactory.getLogger(GameService.class); + + /** 活跃游戏世界集合:roomId -> GameWorld */ + private final Map activeGames = new ConcurrentHashMap<>(); + /** 游戏循环集合:roomId -> GameLoop */ + private final Map gameLoops = new ConcurrentHashMap<>(); + /** 游戏状态广播回调 */ + private final GameStateBroadcast broadcaster; + + /** + * 游戏状态广播接口 + */ + public interface GameStateBroadcast { + void broadcast(String roomId, GameWorld world); + } + + /** + * 构造函数 + * + * @param broadcaster 状态广播回调 + */ + public GameService(GameStateBroadcast broadcaster) { + this.broadcaster = broadcaster; + } + + /** + * 启动游戏 + * + * @param room 游戏房间 + * @return 游戏初始化数据,按玩家ID分组 + */ + public Map> startGame(Room room) { + GameWorld world = new GameWorld(); + int index = 0; + List spawnPoints = world.getMap().getSpawnPoints(); + + for (Player player : room.getPlayers()) { + int[] sp = spawnPoints.get(index % spawnPoints.size()); + float wx = sp[0] + 0.5f; + float wy = sp[1] + 0.5f; + + player.setPosition(wx, wy); + world.addPlayer(player); + index++; + } + + activeGames.put(room.getId(), world); + + Map> playerInitData = new LinkedHashMap<>(); + for (Player player : room.getPlayers()) { + Map data = new LinkedHashMap<>(); + data.put("playerId", player.getId()); + data.put("mapData", serializeMapData(world.getMapData())); + + List> playerList = new ArrayList<>(); + int idx = 0; + for (Player p : room.getPlayers()) { + Map pm = new LinkedHashMap<>(); + pm.put("id", p.getId()); + pm.put("name", p.getName()); + pm.put("x", p.getX()); + pm.put("y", p.getY()); + pm.put("index", idx++); + playerList.add(pm); + } + data.put("players", playerList); + playerInitData.put(player.getId(), data); + } + + GameLoop loop = new GameLoop(room.getId(), world, broadcaster); + gameLoops.put(room.getId(), loop); + loop.start(); + + logger.info("Game started for room: {}", room.getId()); + return playerInitData; + } + + /** + * 停止游戏 + * + * @param roomId 房间ID + */ + public void stopGame(String roomId) { + GameLoop loop = gameLoops.remove(roomId); + if (loop != null) { + loop.stop(); + } + activeGames.remove(roomId); + logger.info("Game stopped for room: {}", roomId); + } + + /** + * 处理玩家输入 + * + * @param roomId 房间ID + * @param playerId 玩家ID + * @param dx X方向移动量 + * @param dy Y方向移动量 + * @param aimX 瞄准X坐标 + * @param aimY 瞄准Y坐标 + * @param firing 是否开火 + * @param weaponIndex 武器索引 + * @param seq 输入序列号 + * @param grenadeCharge 手榴弹蓄力值 + * @param grenadeReleased 手榴弹是否释放 + */ + public void processPlayerInput(String roomId, String playerId, + float dx, float dy, float aimX, float aimY, + boolean firing, int weaponIndex, int seq, + float grenadeCharge, boolean grenadeReleased) { + GameWorld world = activeGames.get(roomId); + if (world == null) return; + + synchronized (world.getLock()) { + Player player = world.getPlayer(playerId); + if (player == null || !player.isAlive()) return; + + if (weaponIndex >= 0 && weaponIndex <= 3) { + player.setWeaponIndex(weaponIndex); + } + + player.applyMovement(dx, dy, world.getMap()); + player.setAngle(aimX, aimY); + player.setLastProcessedSeq(seq); + + if (grenadeReleased && player.hasAmmo() && player.getWeaponIndex() == 3) { + world.fireWeapon(player, aimX, aimY, grenadeCharge); + } else if (firing && player.hasAmmo() && player.getWeaponIndex() != 3) { + world.fireWeapon(player, aimX, aimY); + } + } + } + + /** + * 检查房间是否有活跃游戏 + * + * @param roomId 房间ID + * @return true 表示有活跃游戏 + */ + public boolean hasActiveGame(String roomId) { + return activeGames.containsKey(roomId); + } + + /** + * 序列化地图数据为二维列表 + */ + private List> serializeMapData(int[][] cells) { + List> result = new ArrayList<>(); + for (int[] row : cells) { + List rowList = new ArrayList<>(); + for (int cell : row) { + rowList.add(cell); + } + result.add(rowList); + } + return result; + } +} diff --git a/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java b/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java index edb97be..f13111b 100644 --- a/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java +++ b/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java @@ -6,38 +6,68 @@ import com.zombie.game.model.*; import org.java_websocket.WebSocket; import org.java_websocket.handshake.ClientHandshake; import org.java_websocket.server.WebSocketServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.net.InetSocketAddress; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +/** + * 游戏 WebSocket 服务器 + * + * 处理客户端连接和消息,协调房间管理和游戏实例。 + * 主要功能: + * - 房间创建、加入、离开 + * - 玩家准备和游戏开始 + * - 玩家输入处理 + * - 游戏状态广播 + */ public class GameWebSocketServer extends WebSocketServer { + private static final Logger logger = LoggerFactory.getLogger(GameWebSocketServer.class); + /** JSON 序列化工具 */ private Gson gson; - private Map rooms; + /** 房间管理器 */ + private RoomManager roomManager; + /** 游戏服务 */ + private GameService gameService; + /** WebSocket 连接到玩家ID的映射 */ private Map connectionToPlayer; + /** 玩家ID到WebSocket连接的映射 */ private Map playerToConnection; - private Map activeGames; - private Map gameLoops; + /** 房间列表广播定时器 */ private Timer roomListTimer; + /** + * 构造函数 + * + * @param port 监听端口号 + */ public GameWebSocketServer(int port) { super(new InetSocketAddress(port)); this.gson = new Gson(); - this.rooms = new ConcurrentHashMap<>(); + this.roomManager = new RoomManager(); this.connectionToPlayer = new ConcurrentHashMap<>(); this.playerToConnection = new ConcurrentHashMap<>(); - this.activeGames = new ConcurrentHashMap<>(); - this.gameLoops = new ConcurrentHashMap<>(); + this.gameService = new GameService(this::broadcastGameState); } + /** + * 新连接建立时的回调 + */ @Override public void onOpen(WebSocket conn, ClientHandshake handshake) { - System.out.println("New connection: " + conn.getRemoteSocketAddress()); + logger.info("New connection: {}", conn.getRemoteSocketAddress()); } + /** + * 连接关闭时的回调 + * + * 清理玩家数据并处理离开房间 + */ @Override public void onClose(WebSocket conn, int code, String reason, boolean remote) { - System.out.println("Connection closed: " + conn.getRemoteSocketAddress()); + logger.info("Connection closed: {}", conn.getRemoteSocketAddress()); String playerId = connectionToPlayer.remove(conn); if (playerId != null) { playerToConnection.remove(playerId); @@ -45,6 +75,11 @@ public class GameWebSocketServer extends WebSocketServer { } } + /** + * 收到消息时的回调 + * + * 根据消息类型分发到对应的处理方法 + */ @Override public void onMessage(WebSocket conn, String message) { try { @@ -76,19 +111,27 @@ public class GameWebSocketServer extends WebSocketServer { break; } } catch (Exception e) { - e.printStackTrace(); + logger.error("Error processing message from {}", conn.getRemoteSocketAddress(), e); sendError(conn, "Invalid message format"); } } + /** + * 发生错误时的回调 + */ @Override public void onError(WebSocket conn, Exception ex) { - ex.printStackTrace(); + logger.error("WebSocket error on connection {}", conn != null ? conn.getRemoteSocketAddress() : "null", ex); } + /** + * 服务器启动时的回调 + * + * 启动房间列表广播定时器 + */ @Override public void onStart() { - System.out.println("Game WebSocket Server started on port " + getPort()); + logger.info("Game WebSocket Server started on port {}", getPort()); roomListTimer = new Timer(true); roomListTimer.scheduleAtFixedRate(new TimerTask() { @Override @@ -98,8 +141,15 @@ public class GameWebSocketServer extends WebSocketServer { }, 0, 2000); } + /** + * 处理创建房间请求 + */ private void handleCreateRoom(WebSocket conn, JsonObject data) { - String playerName = data.get("playerName").getAsString(); + if (!MessageUtils.hasRequired(data, "playerName")) { + sendError(conn, "Missing playerName"); + return; + } + String playerName = MessageUtils.getString(data, "playerName"); String playerId = UUID.randomUUID().toString(); String roomId = UUID.randomUUID().toString().substring(0, 8); @@ -107,17 +157,24 @@ public class GameWebSocketServer extends WebSocketServer { playerToConnection.put(playerId, conn); Room room = new Room(roomId, playerId, playerName); - rooms.put(roomId, room); + roomManager.addRoom(roomId, room); sendToConnection(conn, Constants.MSG_ROOM_STATE, room.toStateMap(playerId)); - System.out.println("Room created: " + roomId + " by " + playerName); + logger.info("Room created: {} by {}", roomId, playerName); } + /** + * 处理加入房间请求 + */ private void handleJoinRoom(WebSocket conn, JsonObject data) { - String roomId = data.get("roomId").getAsString(); - String playerName = data.get("playerName").getAsString(); + if (!MessageUtils.hasRequired(data, "roomId", "playerName")) { + sendError(conn, "Missing roomId or playerName"); + return; + } + String roomId = MessageUtils.getString(data, "roomId"); + String playerName = MessageUtils.getString(data, "playerName"); - Room room = rooms.get(roomId); + Room room = roomManager.getRoom(roomId); if (room == null) { sendError(conn, "Room not found"); return; @@ -133,191 +190,151 @@ public class GameWebSocketServer extends WebSocketServer { playerToConnection.put(playerId, conn); room.addPlayer(playerId, playerName); + roomManager.joinRoom(roomId, playerId, playerName); broadcastRoomState(room); - System.out.println("Player " + playerName + " joined room " + roomId); + logger.info("Player {} joined room {}", playerName, roomId); } + /** + * 通过连接处理离开房间 + */ private void handleLeaveRoomByConn(WebSocket conn) { String playerId = connectionToPlayer.get(conn); if (playerId == null) return; handleLeaveRoomByPlayerId(playerId); } + /** + * 通过玩家ID处理离开房间 + */ private void handleLeaveRoomByPlayerId(String playerId) { - for (Room room : new ArrayList<>(rooms.values())) { - if (room.getPlayer(playerId) != null) { - room.removePlayer(playerId); - if (room.getPlayerCount() == 0) { - stopGame(room.getId()); - rooms.remove(room.getId()); - } else { - broadcastRoomState(room); - } - break; - } + Room room = roomManager.leaveRoom(playerId); + if (room == null) { + return; + } + + Room updatedRoom = roomManager.getRoom(room.getId()); + if (updatedRoom == null) { + gameService.stopGame(room.getId()); + } else { + broadcastRoomState(updatedRoom); } } + /** + * 处理获取房间列表请求 + */ private void handleRoomList(WebSocket conn) { List> roomList = new ArrayList<>(); - for (Room room : rooms.values()) { - if (!room.isGameStarted()) { - roomList.add(room.toRoomListMap()); - } + for (Room room : roomManager.getAvailableRooms()) { + roomList.add(room.toRoomListMap()); } Map data = new LinkedHashMap<>(); data.put("rooms", roomList); sendToConnection(conn, Constants.MSG_ROOM_LIST, data); } + /** + * 处理玩家准备请求 + */ private void handleReady(WebSocket conn) { String playerId = connectionToPlayer.get(conn); if (playerId == null) return; - for (Room room : rooms.values()) { - Player player = room.getPlayer(playerId); - if (player != null) { - player.setReady(!player.isReady()); - broadcastRoomState(room); - break; - } + Room room = roomManager.getRoomByPlayerId(playerId); + if (room == null) return; + + Player player = room.getPlayer(playerId); + if (player != null) { + player.setReady(!player.isReady()); + broadcastRoomState(room); } } + /** + * 处理开始游戏请求 + */ private void handleStartGame(WebSocket conn) { String playerId = connectionToPlayer.get(conn); if (playerId == null) return; - for (Room room : rooms.values()) { - if (room.isHost(playerId) && !room.isGameStarted() && room.allReady()) { - room.setGameStarted(true); - startGame(room); - break; - } + Room room = roomManager.getRoomByPlayerId(playerId); + if (room == null) return; + + if (room.isHost(playerId) && !room.isGameStarted() && room.allReady()) { + room.setGameStarted(true); + startGame(room); } } + /** + * 启动游戏 + * + * @param room 游戏房间 + */ private void startGame(Room room) { - GameWorld world = new GameWorld(); - int index = 0; - List spawnPoints = world.getMap().getSpawnPoints(); + Map> playerInitData = gameService.startGame(room); - for (Player player : room.getPlayers()) { - int[] sp = spawnPoints.get(index % spawnPoints.size()); - float wx = sp[0] + 0.5f; - float wy = sp[1] + 0.5f; - - try { - java.lang.reflect.Field xField = Player.class.getDeclaredField("x"); - xField.setAccessible(true); - xField.setFloat(player, wx); - java.lang.reflect.Field yField = Player.class.getDeclaredField("y"); - yField.setAccessible(true); - yField.setFloat(player, wy); - } catch (Exception e) { - e.printStackTrace(); - } - - world.addPlayer(player); - index++; - } - - activeGames.put(room.getId(), world); - - for (Player player : room.getPlayers()) { - Map data = new LinkedHashMap<>(); - data.put("playerId", player.getId()); - data.put("mapData", serializeMapData(world.getMapData())); - - List> playerList = new ArrayList<>(); - int idx = 0; - for (Player p : room.getPlayers()) { - Map pm = new LinkedHashMap<>(); - pm.put("id", p.getId()); - pm.put("name", p.getName()); - pm.put("x", p.getX()); - pm.put("y", p.getY()); - pm.put("index", idx++); - playerList.add(pm); - } - data.put("players", playerList); - - WebSocket pConn = playerToConnection.get(player.getId()); + for (Map.Entry> entry : playerInitData.entrySet()) { + WebSocket pConn = playerToConnection.get(entry.getKey()); if (pConn != null) { - sendToConnection(pConn, Constants.MSG_GAME_STARTED, data); + sendToConnection(pConn, Constants.MSG_GAME_STARTED, entry.getValue()); } } - - GameLoop loop = new GameLoop(room.getId(), world, this); - gameLoops.put(room.getId(), loop); - new Thread(loop).start(); - - System.out.println("Game started for room: " + room.getId()); - } - - private void stopGame(String roomId) { - GameLoop loop = gameLoops.remove(roomId); - if (loop != null) { - loop.stop(); - } - activeGames.remove(roomId); } + /** + * 处理玩家输入 + * + * 接收并处理玩家的移动、射击、武器切换等输入 + */ private void handlePlayerInput(WebSocket conn, JsonObject data) { String playerId = connectionToPlayer.get(conn); if (playerId == null) return; - for (Room room : rooms.values()) { - if (room.isGameStarted()) { - GameWorld world = activeGames.get(room.getId()); - if (world == null) continue; + Room room = roomManager.getRoomByPlayerId(playerId); + if (room == null || !room.isGameStarted()) return; - Player player = world.getPlayer(playerId); - if (player == null) continue; + float dx = MessageUtils.getFloat(data, "dx", 0); + float dy = MessageUtils.getFloat(data, "dy", 0); + float aimX = MessageUtils.getFloat(data, "aimX", 0); + float aimY = MessageUtils.getFloat(data, "aimY", 0); + boolean firing = MessageUtils.getBoolean(data, "firing", false); + int weaponIndex = MessageUtils.getInt(data, "weaponIndex", -1); + int seq = MessageUtils.getInt(data, "seq", 0); + float grenadeCharge = MessageUtils.getFloat(data, "grenadeCharge", 0); + boolean grenadeReleased = MessageUtils.getBoolean(data, "grenadeReleased", false); - float dx = data.has("dx") ? data.get("dx").getAsFloat() : 0; - float dy = data.has("dy") ? data.get("dy").getAsFloat() : 0; - float aimX = data.has("aimX") ? data.get("aimX").getAsFloat() : 0; - float aimY = data.has("aimY") ? data.get("aimY").getAsFloat() : 0; - boolean firing = data.has("firing") && data.get("firing").getAsBoolean(); - int weaponIndex = data.has("weaponIndex") ? data.get("weaponIndex").getAsInt() : -1; - int seq = data.has("seq") ? data.get("seq").getAsInt() : 0; - float grenadeCharge = data.has("grenadeCharge") ? data.get("grenadeCharge").getAsFloat() : 0; - boolean grenadeReleased = data.has("grenadeReleased") && data.get("grenadeReleased").getAsBoolean(); - - if (weaponIndex >= 0 && weaponIndex <= 3) { - player.setWeaponIndex(weaponIndex); - } - - player.applyMovement(dx, dy, world.getMap()); - player.setAngle(aimX, aimY); - player.setLastProcessedSeq(seq); - - if (grenadeReleased && player.hasAmmo() && player.getWeaponIndex() == 3) { - world.fireWeapon(player, aimX, aimY, grenadeCharge); - } else if (firing && player.hasAmmo() && player.getWeaponIndex() != 3) { - world.fireWeapon(player, aimX, aimY); - } - - break; - } - } + gameService.processPlayerInput(room.getId(), playerId, dx, dy, aimX, aimY, + firing, weaponIndex, seq, grenadeCharge, grenadeReleased); } - public void broadcastGameState(String roomId, GameWorld world) { - Room room = rooms.get(roomId); + /** + * 广播游戏状态给房间内所有玩家 + * + * @param roomId 房间ID + * @param world 游戏世界 + */ + private void broadcastGameState(String roomId, GameWorld world) { + Room room = roomManager.getRoom(roomId); if (room == null) return; - for (Player player : room.getPlayers()) { - Map state = world.buildGameState(player.getId()); - WebSocket pConn = playerToConnection.get(player.getId()); - if (pConn != null && pConn.isOpen()) { - sendToConnection(pConn, Constants.MSG_GAME_STATE, state); + Map state = null; + synchronized (world.getLock()) { + for (Player player : room.getPlayers()) { + state = world.buildGameState(player.getId()); + WebSocket pConn = playerToConnection.get(player.getId()); + if (pConn != null && pConn.isOpen()) { + sendToConnection(pConn, Constants.MSG_GAME_STATE, state); + } } } } + /** + * 广播房间状态给房间内所有玩家 + */ private void broadcastRoomState(Room room) { for (Player player : room.getPlayers()) { WebSocket pConn = playerToConnection.get(player.getId()); @@ -327,12 +344,13 @@ public class GameWebSocketServer extends WebSocketServer { } } + /** + * 广播房间列表给所有未加入房间的连接 + */ private void broadcastRoomList() { List> roomList = new ArrayList<>(); - for (Room room : rooms.values()) { - if (!room.isGameStarted()) { - roomList.add(room.toRoomListMap()); - } + for (Room room : roomManager.getAvailableRooms()) { + roomList.add(room.toRoomListMap()); } Map data = new LinkedHashMap<>(); data.put("rooms", roomList); @@ -344,18 +362,13 @@ public class GameWebSocketServer extends WebSocketServer { } } - private List> serializeMapData(int[][] cells) { - List> result = new ArrayList<>(); - for (int[] row : cells) { - List rowList = new ArrayList<>(); - for (int cell : row) { - rowList.add(cell); - } - result.add(rowList); - } - return result; - } - + /** + * 发送消息给指定连接 + * + * @param conn WebSocket 连接 + * @param type 消息类型 + * @param data 消息数据 + */ private void sendToConnection(WebSocket conn, String type, Object data) { if (conn != null && conn.isOpen()) { Map msg = new LinkedHashMap<>(); @@ -365,6 +378,12 @@ public class GameWebSocketServer extends WebSocketServer { } } + /** + * 发送错误消息 + * + * @param conn WebSocket 连接 + * @param message 错误消息 + */ private void sendError(WebSocket conn, String message) { Map data = new LinkedHashMap<>(); data.put("message", message); diff --git a/backend/src/main/java/com/zombie/game/server/MessageUtils.java b/backend/src/main/java/com/zombie/game/server/MessageUtils.java new file mode 100644 index 0000000..8d71abb --- /dev/null +++ b/backend/src/main/java/com/zombie/game/server/MessageUtils.java @@ -0,0 +1,115 @@ +package com.zombie.game.server; + +import com.google.gson.JsonObject; + +/** + * 消息工具类 + * + * 提供安全的 JSON 字段读取方法,避免直接调用 getAsXxx() 时抛出异常。 + */ +public class MessageUtils { + + /** + * 安全获取字符串字段 + * + * @param data JSON 对象 + * @param key 字段名 + * @param defaultValue 默认值 + * @return 字段值或默认值 + */ + public static String getString(JsonObject data, String key, String defaultValue) { + if (data == null || !data.has(key) || data.get(key).isJsonNull()) { + return defaultValue; + } + try { + return data.get(key).getAsString(); + } catch (Exception e) { + return defaultValue; + } + } + + /** + * 安全获取字符串字段(无默认值) + * + * @param data JSON 对象 + * @param key 字段名 + * @return 字段值,不存在或类型错误时返回 null + */ + public static String getString(JsonObject data, String key) { + return getString(data, key, null); + } + + /** + * 安全获取浮点数字段 + * + * @param data JSON 对象 + * @param key 字段名 + * @param defaultValue 默认值 + * @return 字段值或默认值 + */ + public static float getFloat(JsonObject data, String key, float defaultValue) { + if (data == null || !data.has(key) || data.get(key).isJsonNull()) { + return defaultValue; + } + try { + return data.get(key).getAsFloat(); + } catch (Exception e) { + return defaultValue; + } + } + + /** + * 安全获取整数字段 + * + * @param data JSON 对象 + * @param key 字段名 + * @param defaultValue 默认值 + * @return 字段值或默认值 + */ + public static int getInt(JsonObject data, String key, int defaultValue) { + if (data == null || !data.has(key) || data.get(key).isJsonNull()) { + return defaultValue; + } + try { + return data.get(key).getAsInt(); + } catch (Exception e) { + return defaultValue; + } + } + + /** + * 安全获取布尔字段 + * + * @param data JSON 对象 + * @param key 字段名 + * @param defaultValue 默认值 + * @return 字段值或默认值 + */ + public static boolean getBoolean(JsonObject data, String key, boolean defaultValue) { + if (data == null || !data.has(key) || data.get(key).isJsonNull()) { + return defaultValue; + } + try { + return data.get(key).getAsBoolean(); + } catch (Exception e) { + return defaultValue; + } + } + + /** + * 检查必填字段是否存在且有效 + * + * @param data JSON 对象 + * @param keys 必填字段名列表 + * @return true 表示所有必填字段都存在 + */ + public static boolean hasRequired(JsonObject data, String... keys) { + if (data == null) return false; + for (String key : keys) { + if (!data.has(key) || data.get(key).isJsonNull()) { + return false; + } + } + return true; + } +} diff --git a/backend/src/main/java/com/zombie/game/server/RoomManager.java b/backend/src/main/java/com/zombie/game/server/RoomManager.java new file mode 100644 index 0000000..15f9b5e --- /dev/null +++ b/backend/src/main/java/com/zombie/game/server/RoomManager.java @@ -0,0 +1,147 @@ +package com.zombie.game.server; + +import com.zombie.game.model.Player; +import com.zombie.game.model.Room; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 房间管理器 + * + * 负责房间的创建、查询、玩家加入/离开等管理操作。 + * 维护 playerId -> roomId 的映射,实现 O(1) 的玩家房间查找。 + */ +public class RoomManager { + /** 房间集合:roomId -> Room */ + private final Map rooms = new ConcurrentHashMap<>(); + /** 玩家到房间的映射:playerId -> roomId */ + private final Map playerToRoom = new ConcurrentHashMap<>(); + + /** + * 创建房间 + * + * @param roomId 房间ID + * @param room 房间实例 + */ + public void addRoom(String roomId, Room room) { + rooms.put(roomId, room); + for (Player player : room.getPlayers()) { + playerToRoom.put(player.getId(), roomId); + } + } + + /** + * 玩家加入房间 + * + * @param roomId 房间ID + * @param playerId 玩家ID + * @param playerName 玩家名称 + * @return true 表示加入成功 + */ + public boolean joinRoom(String roomId, String playerId, String playerName) { + Room room = rooms.get(roomId); + if (room == null) return false; + if (room.getPlayerCount() >= 4) return false; + + boolean added = room.addPlayer(playerId, playerName); + if (added) { + playerToRoom.put(playerId, roomId); + } + return added; + } + + /** + * 玩家离开房间 + * + * @param playerId 玩家ID + * @return 离开后的房间,如果房间已空则返回 null + */ + public Room leaveRoom(String playerId) { + String roomId = playerToRoom.remove(playerId); + if (roomId == null) return null; + + Room room = rooms.get(roomId); + if (room == null) return null; + + room.removePlayer(playerId); + if (room.getPlayerCount() == 0) { + rooms.remove(roomId); + return null; + } + return room; + } + + /** + * 获取玩家所在的房间 + * + * @param playerId 玩家ID + * @return 房间实例,未找到则返回 null + */ + public Room getRoomByPlayerId(String playerId) { + String roomId = playerToRoom.get(playerId); + if (roomId == null) return null; + return rooms.get(roomId); + } + + /** + * 获取指定ID的房间 + * + * @param roomId 房间ID + * @return 房间实例,未找到则返回 null + */ + public Room getRoom(String roomId) { + return rooms.get(roomId); + } + + /** + * 获取所有房间 + * + * @return 房间集合 + */ + public Collection getAllRooms() { + return rooms.values(); + } + + /** + * 获取未开始游戏的房间列表 + * + * @return 可加入的房间列表 + */ + public List getAvailableRooms() { + List available = new ArrayList<>(); + for (Room room : rooms.values()) { + if (!room.isGameStarted()) { + available.add(room); + } + } + return available; + } + + /** + * 移除房间 + * + * @param roomId 房间ID + */ + public void removeRoom(String roomId) { + Room room = rooms.remove(roomId); + if (room != null) { + for (Player player : room.getPlayers()) { + playerToRoom.remove(player.getId()); + } + } + } + + /** + * 检查玩家是否在房间中 + * + * @param playerId 玩家ID + * @return true 表示在房间中 + */ + public boolean isPlayerInRoom(String playerId) { + return playerToRoom.containsKey(playerId); + } +} diff --git a/frontend/src/game/engine.js b/frontend/src/game/engine.js index 2e897b7..7449d0e 100644 --- a/frontend/src/game/engine.js +++ b/frontend/src/game/engine.js @@ -4,55 +4,89 @@ import { InputManager } from '../utils/input.js' import { GameScene } from './scene.js' import { NetworkClient } from '../network/client.js' +/** + * 游戏引擎核心类 + * 负责游戏逻辑、网络通信、客户端预测和渲染协调 + */ export class GameEngine { constructor(canvas) { this.canvas = canvas + // 游戏场景,负责3D渲染 this.scene = new GameScene(canvas) + // 输入管理器,处理键盘鼠标输入 this.input = new InputManager() + // 网络客户端,负责与服务器通信 this.network = new NetworkClient() + // 地图网格,用于碰撞检测 this.grid = null + // 地图数据 this.mapData = null + // 本地玩家ID this.localPlayerId = null + // 所有玩家列表 Map this.players = new Map() + // 僵尸列表 this.zombies = new Map() + // 玩家子弹列表 this.bullets = new Map() + // 僵尸子弹列表 this.zombieBullets = new Map() + // 掉落物列表 this.loots = new Map() + // 游戏运行状态 this.running = false + // 上次tick时间戳 this.lastTick = 0 + // 时间累加器,用于固定时间步长 this.accumulator = 0 + // 待处理的输入序列(用于客户端预测) this.pendingInputs = [] + // 服务器状态历史 this.serverStates = [] + // 当前武器索引 this.currentWeaponIndex = 0 + // 武器弹药数量 this.weaponAmmo = { - [WEAPONS.PISTOL]: Infinity, + [WEAPONS.PISTOL]: Infinity, // 手枪无限弹药 [WEAPONS.MACHINE_GUN]: 100, [WEAPONS.SHOTGUN]: 20, [WEAPONS.GRENADE]: 10 } - this.grenadeChargeStart = 0 - this.isChargingGrenade = false - this.grenadeChargePercent = 0 - this.grenadeReleased = false + // 手雷蓄力相关 + this.grenadeChargeStart = 0 // 蓄力开始时间 + this.isChargingGrenade = false // 是否正在蓄力 + this.grenadeChargePercent = 0 // 蓄力百分比 0-1 + this.grenadeReleased = false // 是否已释放 - this.gameTime = 0 - this.waveNumber = 0 - this.score = 0 + // 游戏状态 + this.gameTime = 0 // 游戏已进行时间 + this.waveNumber = 0 // 当前波次 + this.score = 0 // 分数 + // 状态更新回调 this.onStateUpdate = null } + /** + * 连接到游戏服务器 + * @param {string} url 服务器地址 + */ async connect(url) { await this.network.connect(url) this._setupNetworkHandlers() } + /** + * 设置网络消息处理器 + * 监听服务器发送的各种游戏消息 + */ _setupNetworkHandlers() { + // 游戏开始消息 this.network.on(MSG_TYPE.GAME_STARTED, (data) => { this.localPlayerId = data.playerId this.mapData = data.mapData || generateDefaultMap() @@ -63,24 +97,33 @@ export class GameEngine { if (this.onStateUpdate) this.onStateUpdate('game_started', data) }) + // 游戏状态同步消息(服务器定期发送) this.network.on(MSG_TYPE.GAME_STATE, (data) => { this._processServerState(data) }) + // 玩家加入消息 this.network.on(MSG_TYPE.PLAYER_JOIN, (data) => { this._addPlayer(data) }) + // 玩家离开消息 this.network.on(MSG_TYPE.PLAYER_LEAVE, (data) => { this._removePlayer(data) }) + // 服务器错误消息 this.network.on(MSG_TYPE.ERROR, (data) => { console.error('Server error:', data.message) }) } + /** + * 初始化所有玩家 + * @param {Array} playersData 服务器提供的玩家数据 + */ _initPlayers(playersData) { + // 玩家颜色列表,用于区分不同玩家 const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44] for (const p of playersData) { const isLocal = p.id === this.localPlayerId @@ -100,6 +143,10 @@ export class GameEngine { } } + /** + * 添加新玩家 + * @param {Object} data 玩家数据 + */ _addPlayer(data) { const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44] const isLocal = data.id === this.localPlayerId @@ -118,11 +165,19 @@ export class GameEngine { this.scene.addPlayer(data.id, data.x, data.y, color, isLocal) } + /** + * 移除离开的玩家 + * @param {Object} data 包含玩家ID的数据 + */ _removePlayer(data) { this.players.delete(data.id) this.scene.removePlayer(data.id) } + /** + * 启动游戏引擎 + * 开始游戏循环 + */ start() { if (this.running) return this.running = true @@ -131,11 +186,18 @@ export class GameEngine { this._loop() } + /** + * 停止游戏引擎 + */ stop() { this.running = false this.input.detach() } + /** + * 游戏主循环 + * 使用requestAnimationFrame实现,固定时间步长进行逻辑更新 + */ _loop() { if (!this.running) return requestAnimationFrame(() => this._loop()) @@ -144,73 +206,101 @@ export class GameEngine { const delta = now - this.lastTick this.lastTick = now + // 累加时间,使用固定时间步长更新游戏逻辑 this.accumulator += delta while (this.accumulator >= TICK_INTERVAL) { this._tick() this.accumulator -= TICK_INTERVAL } + // 更新摄像机跟随本地玩家 const localPlayer = this.players.get(this.localPlayerId) if (localPlayer) { this.scene.updateCamera(localPlayer.x, localPlayer.y) + // 手雷瞄准指示器 if (this.isChargingGrenade && this.currentWeaponIndex === 3) { - this.scene.showGrenadeTarget(localPlayer.x, localPlayer.y, + this.scene.showGrenadeTarget(localPlayer.x, localPlayer.y, this.input.mouse.groundX || 0, this.input.mouse.groundY || 0, this.grenadeChargePercent) } else { this.scene.hideGrenadeTarget() } } + // 渲染画面 this.scene.render() } + /** + * 游戏逻辑Tick + * 每个固定时间步长调用一次,处理输入和网络同步 + */ _tick() { if (!this.localPlayerId) return + const localPlayer = this.players.get(this.localPlayerId) + if (!localPlayer || localPlayer.health <= 0) return + + // 获取鼠标在游戏世界中的地面位置 const mouseGroundPos = this.scene.getMouseGroundPos(this.input.mouse.x, this.input.mouse.y) this.input.mouse.groundX = mouseGroundPos.x this.input.mouse.groundY = mouseGroundPos.y + // 构建输入状态 const inputState = this.input.buildInputState(mouseGroundPos) + // 武器切换 const weaponIdx = inputState.weaponIndex if (weaponIdx >= 0 && weaponIdx !== this.currentWeaponIndex) { this.currentWeaponIndex = weaponIdx } + // 处理手雷蓄力 this._handleGrenadeCharge(inputState) + // 应用本地预测(客户端预测) this._applyLocalPrediction(inputState) + // 添加手雷相关数据到输入状态 inputState.grenadeCharge = this.grenadeChargePercent inputState.grenadeReleased = this.grenadeReleased + // 手雷释放前不发火 inputState.firing = this.currentWeaponIndex === 3 ? false : inputState.firing + // 保存输入以便进行客户端预测校正 this.pendingInputs.push(inputState) if (this.pendingInputs.length > 60) { this.pendingInputs.splice(0, this.pendingInputs.length - 60) } + // 发送输入到服务器 this.network.sendInput(inputState) + // 重置手雷状态 if (this.grenadeReleased) { this.grenadeReleased = false this.grenadeChargePercent = 0 } } + /** + * 处理手雷蓄力逻辑 + * 手雷需要长按鼠标蓄力,松开释放 + */ _handleGrenadeCharge(inputState) { const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE] const currentWeapon = weaponList[this.currentWeaponIndex] if (currentWeapon === WEAPONS.GRENADE && WEAPON_CONFIG[WEAPONS.GRENADE].chargeable) { + // 开始蓄力 if (inputState.firing && !this.isChargingGrenade) { this.isChargingGrenade = true this.grenadeChargeStart = Date.now() } else if (inputState.firing && this.isChargingGrenade) { + // 蓄力中,计算蓄力百分比 const elapsed = Date.now() - this.grenadeChargeStart this.grenadeChargePercent = Math.min(1, elapsed / WEAPON_CONFIG[WEAPONS.GRENADE].maxCharge) } else if (!inputState.firing && this.isChargingGrenade) { + // 释放手雷 this.grenadeReleased = true this.isChargingGrenade = false const elapsed = Date.now() - this.grenadeChargeStart @@ -222,6 +312,10 @@ export class GameEngine { } } + /** + * 应用本地预测 + * 在收到服务器确认前,先本地计算玩家位置和角度 + */ _applyLocalPrediction(inputState) { const player = this.players.get(this.localPlayerId) if (!player) return @@ -231,17 +325,20 @@ export class GameEngine { let newX = player.x + inputState.dx * speed * dt let newY = player.y + inputState.dy * speed * dt + // 碰撞检测,使用0.8半径检测 if (this.grid) { if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y } + // 边界限制 newX = Math.max(0.5, Math.min(31.5, newX)) newY = Math.max(0.5, Math.min(31.5, newY)) player.x = newX player.y = newY + // 计算玩家朝向角度(朝向鼠标位置) const dx = inputState.aimX - player.x const dy = inputState.aimY - player.y player.angle = Math.atan2(dx, dy) @@ -249,30 +346,41 @@ export class GameEngine { this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health) } + /** + * 处理服务器状态同步 + * 根据服务器状态更新游戏世界 + */ _processServerState(state) { + // 更新所有玩家状态 if (state.players) { for (const ps of state.players) { const player = this.players.get(ps.id) if (player) { if (ps.id === this.localPlayerId) { + // 本地玩家需要校正(客户端预测校正) this._reconcileLocalPlayer(ps) } else { + // 远程玩爱直接使用服务器数据 player.x = ps.x player.y = ps.y player.angle = ps.angle player.health = ps.health player.weaponIndex = ps.weaponIndex || 0 + player.waitingForRespawn = ps.waitingForRespawn || false + player.respawnTimer = ps.respawnTimer || 0 this.scene.updatePlayer(ps.id, ps.x, ps.y, ps.angle, ps.health) } } } } + // 更新僵尸状态 if (state.zombies) { const serverZombieIds = new Set() for (const zs of state.zombies) { serverZombieIds.add(zs.id) if (this.zombies.has(zs.id)) { + // 更新现有僵尸 const zombie = this.zombies.get(zs.id) const prevHealth = zombie.health zombie.x = zs.x @@ -280,15 +388,19 @@ export class GameEngine { zombie.health = zs.health const angle = zs.angle || 0 this.scene.updateZombie(zs.id, zs.x, zs.y, angle, zs.health) + // 受伤特效 if (prevHealth > zs.health && zs.health > 0) { this.scene.addHitEffect(zs.x, zs.y) } } else { + // 新增僵尸 const isElite = zs.isElite || false - this.zombies.set(zs.id, { id: zs.id, x: zs.x, y: zs.y, health: zs.health, angle: zs.angle || 0, isElite }) - this.scene.addZombie(zs.id, zs.x, zs.y, isElite) + const isSplitter = zs.isSplitter || false + this.zombies.set(zs.id, { id: zs.id, x: zs.x, y: zs.y, health: zs.health, angle: zs.angle || 0, isElite, isSplitter }) + this.scene.addZombie(zs.id, zs.x, zs.y, isElite, isSplitter) } } + // 移除服务器不再发送的僵尸(死亡或消失) for (const [id, zombie] of this.zombies) { if (!serverZombieIds.has(id)) { this.scene.addHitEffect(zombie.x, zombie.y) @@ -298,16 +410,20 @@ export class GameEngine { } } + // 更新玩家子弹 if (state.bullets) { for (const bs of state.bullets) { if (!this.bullets.has(bs.id)) { + // 新增子弹 this.bullets.set(bs.id, { ...bs }) this.scene.addBullet(bs) + // 枪口火焰特效(手雷除外) const player = this.players.get(bs.ownerId) if (player && bs.weapon !== WEAPONS.GRENADE) { this.scene.addMuzzleFlash(player.x, player.y, bs.angle || 0) } } else { + // 更新子弹位置 const bullet = this.bullets.get(bs.id) bullet.x = bs.x bullet.y = bs.y @@ -315,6 +431,7 @@ export class GameEngine { this.scene.updateBullet(bs.id, bs.x, bs.y, bs.z) } } + // 移除消失的子弹并检测命中 const serverBulletIds = new Set(state.bullets.map(b => b.id)) for (const [id, bullet] of this.bullets) { if (!serverBulletIds.has(id)) { @@ -325,6 +442,7 @@ export class GameEngine { } } + // 移除子弹(服务器明确要求移除) if (state.removedBullets) { for (const id of state.removedBullets) { if (this.bullets.has(id)) { @@ -336,6 +454,7 @@ export class GameEngine { } } + // 爆炸效果 if (state.explosions) { console.log('Explosions received:', state.explosions) for (const exp of state.explosions) { @@ -344,12 +463,14 @@ export class GameEngine { } } + // 命中效果 if (state.hits) { for (const hit of state.hits) { this.scene.addHitEffect(hit.x, hit.y) } } + // 更新僵尸子弹 if (state.zombieBullets) { for (const bs of state.zombieBullets) { if (!this.zombieBullets.has(bs.id)) { @@ -371,6 +492,7 @@ export class GameEngine { } } + // 移除僵尸子弹 if (state.removedZombieBullets) { for (const id of state.removedZombieBullets) { this.zombieBullets.delete(id) @@ -378,6 +500,7 @@ export class GameEngine { } } + // 更新掉落物 if (state.loots) { for (const ls of state.loots) { if (!this.loots.has(ls.id)) { @@ -394,12 +517,14 @@ export class GameEngine { } } + // 更新弹药 if (state.ammo) { for (const [weapon, ammo] of Object.entries(state.ammo)) { this.weaponAmmo[weapon] = ammo } } + // 更新游戏状态 if (state.gameTime !== undefined) this.gameTime = state.gameTime if (state.waveNumber !== undefined) this.waveNumber = state.waveNumber if (state.score !== undefined) this.score = state.score @@ -407,6 +532,10 @@ export class GameEngine { if (this.onStateUpdate) this.onStateUpdate('state', state) } + /** + * 检测子弹是否击中僵尸 + * @param {Object} bullet 子弹数据 + */ _checkBulletHit(bullet) { if (!bullet) return for (const [, zombie] of this.zombies) { @@ -420,48 +549,66 @@ export class GameEngine { } } + /** + * 客户端预测校正 + * 根据服务器状态重新计算本地玩家位置,重放未确认的输入 + */ _reconcileLocalPlayer(serverState) { const player = this.players.get(this.localPlayerId) if (!player) return let lastProcessedSeq = serverState.lastProcessedSeq || 0 + // 直接使用服务器位置 player.x = serverState.x player.y = serverState.y player.angle = serverState.angle player.health = serverState.health + player.waitingForRespawn = serverState.waitingForRespawn || false + player.respawnTimer = serverState.respawnTimer || 0 + // 丢弃已确认的输入 this.pendingInputs = this.pendingInputs.filter(input => input.seq > lastProcessedSeq) - const speed = PLAYER_CONFIG.SPEED - const dt = TICK_INTERVAL / 1000 - for (const input of this.pendingInputs) { - let newX = player.x + input.dx * speed * dt - let newY = player.y + input.dy * speed * dt + // 重放未确认的输入,重新计算位置 + if (player.health > 0) { + const speed = PLAYER_CONFIG.SPEED + const dt = TICK_INTERVAL / 1000 + for (const input of this.pendingInputs) { + let newX = player.x + input.dx * speed * dt + let newY = player.y + input.dy * speed * dt - if (this.grid) { - if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x - if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y + if (this.grid) { + if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x + if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y + } + + newX = Math.max(0.5, Math.min(31.5, newX)) + newY = Math.max(0.5, Math.min(31.5, newY)) + + player.x = newX + player.y = newY + + const dx = input.aimX - player.x + const dy = input.aimY - player.y + player.angle = Math.atan2(dx, dy) } - - newX = Math.max(0.5, Math.min(31.5, newX)) - newY = Math.max(0.5, Math.min(31.5, newY)) - - player.x = newX - player.y = newY - - const dx = input.aimX - player.x - const dy = input.aimY - player.y - player.angle = Math.atan2(dx, dy) } this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health) } + /** + * 获取手雷蓄力百分比 + */ getGrenadeChargePercent() { return this.grenadeChargePercent } + /** + * 销毁游戏引擎 + * 释放所有资源 + */ destroy() { this.stop() this.network.disconnect() diff --git a/frontend/src/game/scene.js b/frontend/src/game/scene.js index e0704ca..68b6fcd 100644 --- a/frontend/src/game/scene.js +++ b/frontend/src/game/scene.js @@ -1,42 +1,62 @@ import * as THREE from 'three' import { GRID_SIZE, PLAYER_SIZE, ZOMBIE_SIZE, WEAPONS, WEAPON_CONFIG } from '../utils/constants.js' +/** + * 游戏场景类 + * 使用Three.js管理3D渲染、摄像机、玩家、僵尸、子弹、特效等 + */ export class GameScene { constructor(canvas) { this.canvas = canvas + + // Three.js核心组件 this.scene = new THREE.Scene() this.scene.background = new THREE.Color(0x1a1a2e) + // 透视摄像机 this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200) + // 摄像机相对于玩家的偏移位置(斜上方俯视角度) this.cameraOffset = new THREE.Vector3(0, 25, 18) + // WebGL渲染器 this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }) this.renderer.setSize(window.innerWidth, window.innerHeight) this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) this.renderer.shadowMap.enabled = true this.renderer.shadowMap.type = THREE.PCFSoftShadowMap - this.players = new Map() - this.zombies = new Map() - this.bullets = [] - this.zombieBullets = [] - this.loots = new Map() - this.effects = [] - this.wallMeshes = [] + // 游戏对象映射 + this.players = new Map() // Map + this.zombies = new Map() // Map + this.bullets = [] // 玩家子弹数组 + this.zombieBullets = [] // 僵尸子弹数组 + this.loots = new Map() // Map + this.effects = [] // 特效数组 + this.wallMeshes = [] // 墙壁网格 + + // 摄像机辅助 this.gridHelper = null - this.playerMesh = null - this.bulletTrails = [] + this.playerMesh = null // 本地玩家网格引用 + + // 手雷目标指示器 this.grenadeTargetGroup = null + // 初始化 this._setupLighting() this._setupResize() this._setupGrenadeTarget() } + /** + * 设置光照 + * 环境光 + 主方向光(带阴影)+ 点光源 + */ _setupLighting() { + // 环境光 const ambient = new THREE.AmbientLight(0x404060, 0.6) this.scene.add(ambient) + // 主方向光 const dirLight = new THREE.DirectionalLight(0xffeedd, 0.8) dirLight.position.set(16, 30, 16) dirLight.castShadow = true @@ -50,11 +70,15 @@ export class GameScene { dirLight.shadow.camera.bottom = -20 this.scene.add(dirLight) + // 点光源(橙色,模拟爆炸/火光) const pointLight = new THREE.PointLight(0xff4400, 0.3, 50) pointLight.position.set(16, 10, 16) this.scene.add(pointLight) } + /** + * 设置窗口调整处理 + */ _setupResize() { window.addEventListener('resize', () => { this.camera.aspect = window.innerWidth / window.innerHeight @@ -63,10 +87,15 @@ export class GameScene { }) } + /** + * 设置手雷目标指示器 + * 由外圈、内圈、十字线组成 + */ _setupGrenadeTarget() { this.grenadeTargetGroup = new THREE.Group() this.grenadeTargetGroup.visible = false + // 外圈 const ringGeo = new THREE.RingGeometry(2.8, 3.0, 32) const ringMat = new THREE.MeshBasicMaterial({ color: 0xff4400, @@ -79,6 +108,7 @@ export class GameScene { ring.position.y = 0.05 this.grenadeTargetGroup.add(ring) + // 内圈 const innerRingGeo = new THREE.RingGeometry(0.8, 1.0, 32) const innerRingMat = new THREE.MeshBasicMaterial({ color: 0xffaa00, @@ -91,6 +121,7 @@ export class GameScene { innerRing.position.y = 0.05 this.grenadeTargetGroup.add(innerRing) + // 十字线 const crossGeo = new THREE.BufferGeometry() const crossPoints = [ -0.3, 0.05, 0, 0.3, 0.05, 0, @@ -104,30 +135,44 @@ export class GameScene { this.scene.add(this.grenadeTargetGroup) } + /** + * 显示手雷目标指示器 + * @param {number} playerX 玩家X坐标 + * @param {number} playerY 玩家Y坐标 + * @param {number} aimX 目标X坐标 + * @param {number} aimY 目标Y坐标 + * @param {number} chargePercent 蓄力百分比 + */ showGrenadeTarget(playerX, playerY, aimX, aimY, chargePercent) { if (!this.grenadeTargetGroup) return + // 根据蓄力百分比计算投掷距离 const minDist = 3.0 const maxDist = 15.0 const dist = minDist + (maxDist - minDist) * chargePercent + // 计算目标方向 const dx = aimX - playerX const dy = aimY - playerY const targetDist = Math.sqrt(dx * dx + dy * dy) let targetX, targetY if (targetDist < 0.1) { + // 如果目标太近,默认向前 targetX = playerX + minDist targetY = playerY } else { + // 按比例缩放到蓄力距离 const scale = Math.min(dist, targetDist) / targetDist targetX = playerX + dx * scale targetY = playerY + dy * scale } + // 限制在地图边界内 targetX = Math.max(0.5, Math.min(31.5, targetX)) targetY = Math.max(0.5, Math.min(31.5, targetY)) + // 更新指示器位置和缩放 this.grenadeTargetGroup.position.x = targetX this.grenadeTargetGroup.position.z = targetY this.grenadeTargetGroup.visible = true @@ -135,16 +180,25 @@ export class GameScene { const scale = 1 + chargePercent * 0.3 this.grenadeTargetGroup.scale.setScalar(scale) + // 根据蓄力调整透明度 this.grenadeTargetGroup.children[0].material.opacity = 0.4 + chargePercent * 0.4 } + /** + * 隐藏手雷目标指示器 + */ hideGrenadeTarget() { if (this.grenadeTargetGroup) { this.grenadeTargetGroup.visible = false } } + /** + * 构建游戏地图 + * @param {Array} mapData 二维数组地图数据 + */ buildMap(mapData) { + // 清除旧的墙壁 for (const mesh of this.wallMeshes) { this.scene.remove(mesh) mesh.geometry.dispose() @@ -152,6 +206,7 @@ export class GameScene { } this.wallMeshes = [] + // 地面 const floorGeo = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE) const floorMat = new THREE.MeshLambertMaterial({ color: 0x2a2a3a }) const floor = new THREE.Mesh(floorGeo, floorMat) @@ -161,18 +216,23 @@ export class GameScene { this.scene.add(floor) this.wallMeshes.push(floor) + // 墙壁几何体 const wallGeo = new THREE.BoxGeometry(1, 1.5, 1) const wallMat = new THREE.MeshLambertMaterial({ color: 0x555577 }) + // 玩家出生点标记 const spawnGeo = new THREE.BoxGeometry(1, 0.1, 1) const spawnMat = new THREE.MeshLambertMaterial({ color: 0x00ff88, transparent: true, opacity: 0.5 }) + // 僵尸出生点标记 const zombieSpawnGeo = new THREE.BoxGeometry(1, 0.15, 1) const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4400, transparent: true, opacity: 0.7 }) + // 遍历地图数据 for (let y = 0; y < GRID_SIZE; y++) { for (let x = 0; x < GRID_SIZE; x++) { if (mapData[y] && mapData[y][x] === 1) { + // 墙壁 const wall = new THREE.Mesh(wallGeo, wallMat) wall.position.set(x + 0.5, 0.75, y + 0.5) wall.castShadow = true @@ -180,11 +240,13 @@ export class GameScene { this.scene.add(wall) this.wallMeshes.push(wall) } else if (mapData[y] && mapData[y][x] === 2) { + // 玩家出生点 const spawn = new THREE.Mesh(spawnGeo, spawnMat) spawn.position.set(x + 0.5, 0.05, y + 0.5) this.scene.add(spawn) this.wallMeshes.push(spawn) } else if (mapData[y] && mapData[y][x] === 3) { + // 僵尸出生点 const zombieSpawn = new THREE.Mesh(zombieSpawnGeo, zombieSpawnMat) zombieSpawn.position.set(x + 0.5, 0.08, y + 0.5) this.scene.add(zombieSpawn) @@ -194,9 +256,15 @@ export class GameScene { } } + /** + * 创建玩家3D模型 + * @param {number} color 玩家颜色 + * @returns {THREE.Group} 玩家模型组 + */ createPlayerModel(color = 0x4488ff) { const group = new THREE.Group() + // 身体(圆柱) const bodyGeo = new THREE.CylinderGeometry(PLAYER_SIZE / 2, PLAYER_SIZE / 2, 0.8, 8) const bodyMat = new THREE.MeshLambertMaterial({ color }) const body = new THREE.Mesh(bodyGeo, bodyMat) @@ -204,6 +272,7 @@ export class GameScene { body.castShadow = true group.add(body) + // 头部(球体) const headGeo = new THREE.SphereGeometry(0.2, 8, 8) const headMat = new THREE.MeshLambertMaterial({ color: 0xffcc99 }) const head = new THREE.Mesh(headGeo, headMat) @@ -211,6 +280,7 @@ export class GameScene { head.castShadow = true group.add(head) + // 枪械(长方体) const gunGeo = new THREE.BoxGeometry(0.08, 0.08, 0.5) const gunMat = new THREE.MeshLambertMaterial({ color: 0x333333 }) const gun = new THREE.Mesh(gunGeo, gunMat) @@ -221,13 +291,32 @@ export class GameScene { return group } - createZombieModel(isElite = false) { + /** + * 创建僵尸3D模型 + * @param {boolean} isElite 是否精英怪 + * @param {boolean} isSplitter 是否分裂怪 + * @returns {THREE.Group} 僵尸模型组 + */ + createZombieModel(isElite = false, isSplitter = false) { const group = new THREE.Group() - const bodyColor = isElite ? 0x882222 : 0x446633 - const headColor = isElite ? 0xaa3333 : 0x557744 - const armColor = isElite ? 0xaa3333 : 0x557744 + // 不同类型僵尸颜色不同 + let bodyColor, headColor, armColor + if (isElite) { + bodyColor = 0x882222 + headColor = 0xaa3333 + armColor = 0xaa3333 + } else if (isSplitter) { + bodyColor = 0x886600 + headColor = 0xaa8822 + armColor = 0xaa8822 + } else { + bodyColor = 0x446633 + headColor = 0x557744 + armColor = 0x557744 + } + // 身体 const bodyGeo = new THREE.CylinderGeometry(ZOMBIE_SIZE / 2, ZOMBIE_SIZE / 2, 0.9, 8) const bodyMat = new THREE.MeshLambertMaterial({ color: bodyColor }) const body = new THREE.Mesh(bodyGeo, bodyMat) @@ -235,6 +324,7 @@ export class GameScene { body.castShadow = true group.add(body) + // 头部 const headGeo = new THREE.SphereGeometry(0.22, 8, 8) const headMat = new THREE.MeshLambertMaterial({ color: headColor }) const head = new THREE.Mesh(headGeo, headMat) @@ -242,6 +332,7 @@ export class GameScene { head.castShadow = true group.add(head) + // 手臂 const armGeo = new THREE.BoxGeometry(0.12, 0.6, 0.12) const armMat = new THREE.MeshLambertMaterial({ color: armColor }) const leftArm = new THREE.Mesh(armGeo, armMat) @@ -253,6 +344,7 @@ export class GameScene { rightArm.rotation.x = -0.5 group.add(rightArm) + // 精英僵尸发光效果 if (isElite) { const glowGeo = new THREE.SphereGeometry(0.6, 8, 8) const glowMat = new THREE.MeshBasicMaterial({ @@ -265,9 +357,30 @@ export class GameScene { group.add(glow) } + // 分裂僵尸发光效果 + if (isSplitter) { + const glowGeo = new THREE.SphereGeometry(0.5, 8, 8) + const glowMat = new THREE.MeshBasicMaterial({ + color: 0xffaa00, + transparent: true, + opacity: 0.3 + }) + const glow = new THREE.Mesh(glowGeo, glowMat) + glow.position.y = 0.5 + group.add(glow) + } + return group } + /** + * 添加玩家 + * @param {string} id 玩家ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {number} color 颜色 + * @param {boolean} isLocal 是否本地玩家 + */ addPlayer(id, x, y, color, isLocal = false) { const model = this.createPlayerModel(color) model.position.set(x, 0, y) @@ -276,6 +389,10 @@ export class GameScene { if (isLocal) this.playerMesh = model } + /** + * 移除玩家 + * @param {string} id 玩家ID + */ removePlayer(id) { const player = this.players.get(id) if (player) { @@ -284,6 +401,14 @@ export class GameScene { } } + /** + * 更新玩家位置和角度 + * @param {string} id 玩家ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {number} angle 旋转角度 + * @param {number} health 生命值 + */ updatePlayer(id, x, y, angle, health) { const player = this.players.get(id) if (player) { @@ -296,13 +421,25 @@ export class GameScene { } } - addZombie(id, x, y, isElite = false) { - const model = this.createZombieModel(isElite) + /** + * 添加僵尸 + * @param {string} id 僵尸ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {boolean} isElite 是否精英 + * @param {boolean} isSplitter 是否分裂 + */ + addZombie(id, x, y, isElite = false, isSplitter = false) { + const model = this.createZombieModel(isElite, isSplitter) model.position.set(x, 0, y) this.scene.add(model) - this.zombies.set(id, { model, isElite }) + this.zombies.set(id, { model, isElite, isSplitter }) } + /** + * 移除僵尸 + * @param {string} id 僵尸ID + */ removeZombie(id) { const zombie = this.zombies.get(id) if (zombie) { @@ -315,6 +452,14 @@ export class GameScene { } } + /** + * 更新僵尸位置和角度 + * @param {string} id 僵尸ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {number} angle 旋转角度 + * @param {number} health 生命值 + */ updateZombie(id, x, y, angle, health) { const zombie = this.zombies.get(id) if (zombie) { @@ -324,12 +469,17 @@ export class GameScene { } } + /** + * 添加子弹 + * @param {Object} bullet 子弹数据 + */ addBullet(bullet) { const group = new THREE.Group() const isGrenade = bullet.weapon === WEAPONS.GRENADE const z = bullet.z || 0.5 if (isGrenade) { + // 手雷:球体 + 发光 + 拖尾 const geo = new THREE.SphereGeometry(0.12, 8, 8) const mat = new THREE.MeshBasicMaterial({ color: 0x44ff44 }) const mesh = new THREE.Mesh(geo, mat) @@ -346,6 +496,7 @@ export class GameScene { glow.position.set(bullet.x, z, bullet.y) group.add(glow) + // 拖尾线 const trailGeo = new THREE.BufferGeometry() const trailMat = new THREE.LineBasicMaterial({ color: 0x88ff88, @@ -361,9 +512,11 @@ export class GameScene { const trail = new THREE.Line(trailGeo, trailMat) group.add(trail) } else { + // 普通子弹 let bulletSize = 0.06 const angle = bullet.angle || 0 + // 根据武器类型调整大小 switch (bullet.weapon) { case WEAPONS.MACHINE_GUN: bulletSize = 0.05 @@ -373,6 +526,7 @@ export class GameScene { break } + // 拖尾 const trailLength = 2.5 const trailGeo = new THREE.BufferGeometry() const positions = new Float32Array([ @@ -390,12 +544,14 @@ export class GameScene { const trail = new THREE.Line(trailGeo, trailMat) group.add(trail) + // 子弹本体 const geo = new THREE.SphereGeometry(bulletSize, 8, 8) const mat = new THREE.MeshBasicMaterial({ color: 0xffffaa }) const mesh = new THREE.Mesh(geo, mat) mesh.position.set(bullet.x, 0.5, bullet.y) group.add(mesh) + // 发光效果 const glowGeo = new THREE.SphereGeometry(bulletSize * 2, 8, 8) const glowMat = new THREE.MeshBasicMaterial({ color: 0xffff88, @@ -420,6 +576,10 @@ export class GameScene { }) } + /** + * 移除子弹 + * @param {string} id 子弹ID + */ removeBullet(id) { const idx = this.bullets.findIndex(b => b.id === id) if (idx >= 0) { @@ -433,6 +593,13 @@ export class GameScene { } } + /** + * 更新子弹位置 + * @param {string} id 子弹ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {number} z Z坐标(高度) + */ updateBullet(id, x, y, z) { const bullet = this.bullets.find(b => b.id === id) if (bullet) { @@ -446,6 +613,7 @@ export class GameScene { child.position.y = bullet.z child.position.z = y } + // 更新拖尾位置 if (child.isLine && !bullet.isGrenade) { const trailLength = 2.5 const positions = child.geometry.attributes.position.array @@ -461,10 +629,15 @@ export class GameScene { } } + /** + * 添加僵尸子弹 + * @param {Object} bullet 子弹数据 + */ addZombieBullet(bullet) { const group = new THREE.Group() const angle = bullet.angle || 0 + // 红色拖尾 const trailLength = 1.2 const trailGeo = new THREE.BufferGeometry() const positions = new Float32Array([ @@ -481,6 +654,7 @@ export class GameScene { const trail = new THREE.Line(trailGeo, trailMat) group.add(trail) + // 子弹本体(红色) const geo = new THREE.SphereGeometry(0.08, 6, 6) const mat = new THREE.MeshBasicMaterial({ color: 0xff3333 }) const mesh = new THREE.Mesh(geo, mat) @@ -497,6 +671,10 @@ export class GameScene { }) } + /** + * 移除僵尸子弹 + * @param {string} id 子弹ID + */ removeZombieBullet(id) { const idx = this.zombieBullets.findIndex(b => b.id === id) if (idx >= 0) { @@ -510,6 +688,12 @@ export class GameScene { } } + /** + * 更新僵尸子弹位置 + * @param {string} id 子弹ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + */ updateZombieBullet(id, x, y) { const bullet = this.zombieBullets.find(b => b.id === id) if (bullet) { @@ -536,7 +720,14 @@ export class GameScene { } } + /** + * 添加爆炸效果 + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {number} radius 爆炸半径 + */ addExplosion(x, y, radius) { + // 白色闪光 const whiteFlashGeo = new THREE.SphereGeometry(radius * 0.8, 16, 16) const whiteFlashMat = new THREE.MeshBasicMaterial({ color: 0xffffff, @@ -548,11 +739,13 @@ export class GameScene { this.scene.add(whiteFlash) this.effects.push({ mesh: whiteFlash, type: 'whiteFlash', startTime: Date.now(), duration: 300 }) + // 点光源 const light = new THREE.PointLight(0xffffff, 3, radius * 4) light.position.set(x, 2, y) this.scene.add(light) this.effects.push({ mesh: light, type: 'light', startTime: Date.now(), duration: 400 }) + // 爆炸火球(多层) for (let i = 0; i < 3; i++) { const r = radius * (0.3 + i * 0.2) const geo = new THREE.SphereGeometry(r, 12, 12) @@ -567,6 +760,7 @@ export class GameScene { this.effects.push({ mesh, type: 'explosion', startTime: Date.now(), duration: 500 + i * 100 }) } + // 碎片 for (let i = 0; i < 8; i++) { const angle = (i / 8) * Math.PI * 2 const geo = new THREE.BoxGeometry(0.1, 0.1, 0.1) @@ -586,9 +780,16 @@ export class GameScene { } } + /** + * 添加枪口火焰效果 + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {number} angle 射击方向角度 + */ addMuzzleFlash(x, y, angle) { const dist = 0.6 + // 火焰球 const flashGeo = new THREE.SphereGeometry(0.25, 8, 8) const flashMat = new THREE.MeshBasicMaterial({ color: 0xffff00, @@ -601,6 +802,7 @@ export class GameScene { this.scene.add(flash) this.effects.push({ mesh: flash, type: 'muzzle', startTime: Date.now(), duration: 100 }) + // 发光球 const glowGeo = new THREE.SphereGeometry(0.4, 8, 8) const glowMat = new THREE.MeshBasicMaterial({ color: 0xff6600, @@ -612,13 +814,20 @@ export class GameScene { this.scene.add(glow) this.effects.push({ mesh: glow, type: 'muzzle', startTime: Date.now(), duration: 150 }) + // 点光源 const light = new THREE.PointLight(0xffaa00, 2, 5) light.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist) this.scene.add(light) this.effects.push({ mesh: light, type: 'light', startTime: Date.now(), duration: 100 }) } + /** + * 添加命中效果 + * @param {number} x X坐标 + * @param {number} y Y坐标 + */ addHitEffect(x, y) { + // 红色球体 const geo = new THREE.SphereGeometry(0.2, 8, 8) const mat = new THREE.MeshBasicMaterial({ color: 0xff0000, @@ -630,6 +839,7 @@ export class GameScene { this.scene.add(mesh) this.effects.push({ mesh, type: 'hit', startTime: Date.now(), duration: 200 }) + // 火花粒子 for (let i = 0; i < 5; i++) { const sparkGeo = new THREE.BoxGeometry(0.05, 0.05, 0.05) const sparkMat = new THREE.MeshBasicMaterial({ color: 0xff4400 }) @@ -650,11 +860,19 @@ export class GameScene { } } + /** + * 添加掉落物 + * @param {string} id 掉落物ID + * @param {number} x X坐标 + * @param {number} y Y坐标 + * @param {string} type 类型(ammo/health) + */ addLoot(id, x, y, type = 'ammo') { const isHealth = type === 'health' const color = isHealth ? 0xff4444 : 0x00ffcc const emissive = isHealth ? 0xaa2222 : 0x00aa88 + // 主体 const geo = new THREE.BoxGeometry(0.4, 0.4, 0.4) const mat = new THREE.MeshLambertMaterial({ color, emissive, emissiveIntensity: 0.3 }) const mesh = new THREE.Mesh(geo, mat) @@ -662,6 +880,7 @@ export class GameScene { this.scene.add(mesh) this.loots.set(id, { mesh, type }) + // 医疗包额外显示十字 if (isHealth) { const crossGeo = new THREE.BoxGeometry(0.35, 0.1, 0.1) const crossMat = new THREE.MeshBasicMaterial({ color: 0xffffff }) @@ -676,6 +895,10 @@ export class GameScene { } } + /** + * 移除掉落物 + * @param {string} id 掉落物ID + */ removeLoot(id) { const loot = this.loots.get(id) if (loot) { @@ -693,6 +916,11 @@ export class GameScene { } } + /** + * 更新摄像机位置(平滑跟随玩家) + * @param {number} targetX 目标X坐标 + * @param {number} targetY 目标Y坐标 + */ updateCamera(targetX, targetY) { const target = new THREE.Vector3(targetX, 0, targetY) const desiredPos = target.clone().add(this.cameraOffset) @@ -700,6 +928,12 @@ export class GameScene { this.camera.lookAt(target) } + /** + * 将鼠标屏幕坐标转换为游戏世界地面坐标 + * @param {number} mouseX 鼠标X坐标 + * @param {number} mouseY 鼠标Y坐标 + * @returns {Object} {x, y} 世界坐标 + */ getMouseGroundPos(mouseX, mouseY) { const mouse = new THREE.Vector2( (mouseX / window.innerWidth) * 2 - 1, @@ -716,21 +950,28 @@ export class GameScene { return { x: 0, y: 0 } } + /** + * 更新所有特效(每帧调用) + * 处理特效的缩放、透明度、物理运动等 + */ updateEffects() { const now = Date.now() const gravity = -9.8 + // 遍历所有特效 for (let i = this.effects.length - 1; i >= 0; i--) { const effect = this.effects[i] const elapsed = (now - effect.startTime) / 1000 const progress = elapsed * 1000 / effect.duration + // 特效结束,移除 if (progress >= 1) { this.scene.remove(effect.mesh) if (effect.mesh.geometry) effect.mesh.geometry.dispose() if (effect.mesh.material) effect.mesh.material.dispose() this.effects.splice(i, 1) } else { + // 根据类型更新特效 if (effect.type === 'whiteFlash') { const scale = 1 + progress * 2 effect.mesh.scale.setScalar(scale) @@ -747,6 +988,7 @@ export class GameScene { effect.mesh.material.opacity = 0.9 * (1 - progress) effect.mesh.scale.setScalar(1 + progress * 2) } else if (effect.type === 'spark' || effect.type === 'debris') { + // 物理运动 effect.mesh.position.x += effect.vx * 0.016 effect.mesh.position.z += effect.vz * 0.016 effect.vy += gravity * 0.016 @@ -758,6 +1000,7 @@ export class GameScene { } } + // 更新子弹拖尾 for (const bullet of this.bullets) { if (!bullet.isGrenade && bullet.trail) { const positions = bullet.trail.geometry.attributes.position.array @@ -771,17 +1014,24 @@ export class GameScene { } } + // 掉落物悬浮动画 for (const [, loot] of this.loots) { loot.mesh.rotation.y += 0.03 loot.mesh.position.y = 0.3 + Math.sin(Date.now() * 0.003) * 0.1 } } + /** + * 渲染画面 + */ render() { this.updateEffects() this.renderer.render(this.scene, this.camera) } + /** + * 销毁场景,释放资源 + */ destroy() { this.renderer.dispose() this.scene.traverse(child => { @@ -795,4 +1045,4 @@ export class GameScene { } }) } -} +} \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js index 94e864c..faa3889 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -5,40 +5,59 @@ import { HUD } from './ui/hud.js' import { SettingsUI } from './ui/settings.js' import './style.css' +// WebSocket服务器地址 const WS_URL = `ws://${window.location.hostname}:8080/ws` +/** + * 游戏主应用类 + * 负责管理游戏生命周期、大厅UI、游戏HUD和设置界面 + */ class App { constructor() { + // 主应用容器 this.appEl = document.getElementById('app') + + // 创建各个UI区域的容器 this.lobbyEl = document.createElement('div') this.gameCanvasEl = document.createElement('canvas') this.hudEl = document.createElement('div') this.settingsEl = document.createElement('div') + // 将各容器添加到主容器 this.appEl.appendChild(this.lobbyEl) this.appEl.appendChild(this.gameCanvasEl) this.appEl.appendChild(this.hudEl) this.appEl.appendChild(this.settingsEl) + // 默认隐藏游戏相关界面 this.gameCanvasEl.style.display = 'none' this.hudEl.style.display = 'none' this.settingsEl.style.display = 'none' + // 游戏引擎实例 this.engine = null + // UI组件实例 this.lobby = new LobbyUI(this.lobbyEl) this.hud = new HUD(this.hudEl) this.settings = new SettingsUI(this.settingsEl) + // 玩家状态 this.playerId = null this.roomId = null this.isHost = false this.playerName = '' + // 初始化大厅和设置事件 this._setupLobby() this._setupSettings() } + /** + * 设置大厅界面事件处理 + * 绑定各种大厅操作回调 + */ _setupLobby() { + // 创建房间 this.lobby.onCreateRoom = (name) => { this.playerName = name this._ensureConnection().then(() => { @@ -46,6 +65,7 @@ class App { }) } + // 加入房间 this.lobby.onJoinRoom = (roomId, name) => { this.playerName = name this._ensureConnection().then(() => { @@ -53,20 +73,24 @@ class App { }) } + // 刷新房间列表 this.lobby.onRefreshRooms = () => { this._ensureConnection().then(() => { this.engine.network.requestRoomList() }) } + // 玩家准备 this.lobby.onReady = () => { if (this.engine) this.engine.network.ready() } + // 开始游戏(仅房主) this.lobby.onStartGame = () => { if (this.engine) this.engine.network.startGame() } + // 离开房间 this.lobby.onLeaveRoom = () => { if (this.engine) { this.engine.network.leaveRoom() @@ -77,9 +101,14 @@ class App { } } + /** + * 设置设置界面事件处理 + * 绑定按键配置变更回调 + */ _setupSettings() { this.settings.onKeyChange = (bindings) => { if (this.engine) { + // 更新游戏引擎中的按键绑定 for (const [action, key] of Object.entries(bindings)) { this.engine.input.updateKeyBinding(action, key) } @@ -87,8 +116,13 @@ class App { } } + /** + * 确保网络连接已建立 + * 如果未连接则创建游戏引擎并连接服务器 + */ async _ensureConnection() { if (!this.engine) { + // 首次连接,创建游戏引擎 this.engine = new GameEngine(this.gameCanvasEl) try { await this.engine.connect(WS_URL) @@ -98,6 +132,7 @@ class App { alert('Failed to connect to server. Make sure the server is running on port 8080.') } } else if (!this.engine.network.connected) { + // 断线重连 try { await this.engine.connect(WS_URL) this._setupNetworkHandlers() @@ -107,13 +142,19 @@ class App { } } + /** + * 设置网络消息处理器 + * 监听服务器发送的各种消息 + */ _setupNetworkHandlers() { const net = this.engine.network + // 房间列表更新 net.on(MSG_TYPE.ROOM_LIST, (data) => { this.lobby.updateRoomList(data.rooms) }) + // 房间状态更新 net.on(MSG_TYPE.ROOM_STATE, (data) => { this.playerId = data.playerId || this.playerId this.roomId = data.roomId @@ -121,13 +162,16 @@ class App { this.lobby.showRoom(data, this.isHost, this.playerId) }) + // 游戏开始 net.on(MSG_TYPE.GAME_STARTED, (data) => { this.playerId = data.playerId + // 切换界面显示 this.lobbyEl.style.display = 'none' this.gameCanvasEl.style.display = 'block' this.hudEl.style.display = 'flex' this.hud.show() + // 注册游戏状态更新回调 this.engine.onStateUpdate = (type, state) => { if (type === 'state') { this._updateHUD(state) @@ -135,11 +179,16 @@ class App { } }) + // 服务器错误 net.on(MSG_TYPE.ERROR, (data) => { console.error('Error:', data.message) }) } + /** + * 更新HUD显示 + * 根据游戏状态更新血量、武器、波次等信息 + */ _updateHUD(state) { const localPlayer = this.engine.players.get(this.engine.localPlayerId) if (localPlayer) { @@ -154,6 +203,9 @@ class App { ) } + /** + * 显示设置界面 + */ showSettings() { if (this.engine) { this.settings.show(this.engine.input.getKeyBindings()) @@ -161,9 +213,12 @@ class App { } } +// 创建应用实例 const app = new App() +// 全局键盘事件处理 window.addEventListener('keydown', (e) => { + // ESC键切换设置界面 if (e.code === 'Escape') { if (app.settings.visible) { app.settings.hide() @@ -171,4 +226,4 @@ window.addEventListener('keydown', (e) => { app.showSettings() } } -}) +}) \ No newline at end of file diff --git a/frontend/src/network/client.js b/frontend/src/network/client.js index c519f0a..e40fe02 100644 --- a/frontend/src/network/client.js +++ b/frontend/src/network/client.js @@ -1,27 +1,38 @@ import { MSG_TYPE } from '../utils/constants.js' +/** + * 网络客户端类 + * 负责WebSocket连接和消息收发 + */ export class NetworkClient { constructor() { - this.ws = null - this.handlers = new Map() - this.reconnectAttempts = 0 - this.maxReconnectAttempts = 5 - this.connected = false - this.playerId = null - this.roomId = null + this.ws = null // WebSocket连接 + this.handlers = new Map() // 消息处理器映射 + this.reconnectAttempts = 0 // 重连尝试次数 + this.maxReconnectAttempts = 5 // 最大重连次数 + this.connected = false // 连接状态 + this.playerId = null // 玩家ID + this.roomId = null // 房间ID } + /** + * 连接到WebSocket服务器 + * @param {string} url 服务器地址 + * @returns {Promise} 连接成功/失败 + */ connect(url) { return new Promise((resolve, reject) => { try { this.ws = new WebSocket(url) + // 连接成功 this.ws.onopen = () => { this.connected = true this.reconnectAttempts = 0 resolve() } + // 收到消息 this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data) @@ -31,11 +42,13 @@ export class NetworkClient { } } + // 连接关闭 this.ws.onclose = () => { this.connected = false this._dispatch({ type: MSG_TYPE.ERROR, data: { message: 'Disconnected from server' } }) } + // 连接错误 this.ws.onerror = (err) => { reject(err) } @@ -45,6 +58,9 @@ export class NetworkClient { }) } + /** + * 断开连接 + */ disconnect() { if (this.ws) { this.ws.close() @@ -53,6 +69,11 @@ export class NetworkClient { } } + /** + * 注册消息处理器 + * @param {string} type 消息类型 + * @param {Function} handler 处理函数 + */ on(type, handler) { if (!this.handlers.has(type)) { this.handlers.set(type, []) @@ -60,6 +81,11 @@ export class NetworkClient { this.handlers.get(type).push(handler) } + /** + * 移除消息处理器 + * @param {string} type 消息类型 + * @param {Function} handler 处理函数 + */ off(type, handler) { if (this.handlers.has(type)) { const list = this.handlers.get(type) @@ -68,12 +94,21 @@ export class NetworkClient { } } + /** + * 发送消息到服务器 + * @param {string} type 消息类型 + * @param {Object} data 消息数据 + */ send(type, data = {}) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type, data })) } } + /** + * 分发消息到对应处理器 + * @param {Object} msg 消息对象 {type, data} + */ _dispatch(msg) { const handlers = this.handlers.get(msg.type) if (handlers) { @@ -83,31 +118,58 @@ export class NetworkClient { } } + // ========== 游戏房间操作 ========== + + /** + * 创建房间 + * @param {string} playerName 玩家名称 + */ createRoom(playerName) { this.send(MSG_TYPE.CREATE_ROOM, { playerName }) } + /** + * 加入房间 + * @param {string} roomId 房间ID + * @param {string} playerName 玩家名称 + */ joinRoom(roomId, playerName) { this.send(MSG_TYPE.JOIN_ROOM, { roomId, playerName }) } + /** + * 离开房间 + */ leaveRoom() { this.send(MSG_TYPE.LEAVE_ROOM, {}) } + /** + * 请求房间列表 + */ requestRoomList() { this.send(MSG_TYPE.ROOM_LIST, {}) } + /** + * 玩家准备 + */ ready() { this.send(MSG_TYPE.READY, {}) } + /** + * 开始游戏(房主) + */ startGame() { this.send(MSG_TYPE.START_GAME, {}) } + /** + * 发送玩家输入状态 + * @param {Object} inputState 输入状态 + */ sendInput(inputState) { this.send(MSG_TYPE.PLAYER_INPUT, inputState) } -} +} \ No newline at end of file diff --git a/frontend/src/ui/hud.js b/frontend/src/ui/hud.js index 3ab55ef..7700669 100644 --- a/frontend/src/ui/hud.js +++ b/frontend/src/ui/hud.js @@ -1,5 +1,9 @@ import { WEAPONS, WEAPON_CONFIG } from '../utils/constants.js' +/** + * HUD界面类 + * 显示游戏中的血量、武器、弹药等信息 + */ export class HUD { constructor(container) { this.container = container @@ -9,15 +13,21 @@ export class HUD { this._build() } + /** + * 构建HUD界面元素 + */ _build() { + // 血量条 this.healthBar = document.createElement('div') this.healthBar.className = 'hud-health-bar' this.healthBar.innerHTML = '
100' this.container.appendChild(this.healthBar) + // 武器面板 this.weaponPanel = document.createElement('div') this.weaponPanel.className = 'hud-weapon-panel' + // 武器列表 const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE] this.weaponSlots = [] for (let i = 0; i < weaponList.length; i++) { @@ -25,14 +35,17 @@ export class HUD { slot.className = 'hud-weapon-slot' slot.dataset.index = i + // 按键提示(1-4) const keyLabel = document.createElement('span') keyLabel.className = 'hud-weapon-key' keyLabel.textContent = (i + 1).toString() + // 武器名称 const weaponName = document.createElement('span') weaponName.className = 'hud-weapon-name' weaponName.textContent = WEAPON_CONFIG[weaponList[i]].name + // 弹药数量 const ammoText = document.createElement('span') ammoText.className = 'hud-weapon-ammo' @@ -45,12 +58,14 @@ export class HUD { this.container.appendChild(this.weaponPanel) + // 手雷蓄力条(默认隐藏) this.grenadeChargeBar = document.createElement('div') this.grenadeChargeBar.className = 'hud-grenade-charge' this.grenadeChargeBar.innerHTML = '
' this.grenadeChargeBar.style.display = 'none' this.container.appendChild(this.grenadeChargeBar) + // 信息面板(波次、分数、时间) this.infoPanel = document.createElement('div') this.infoPanel.className = 'hud-info-panel' @@ -71,40 +86,59 @@ export class HUD { this.infoPanel.appendChild(this.timeText) this.container.appendChild(this.infoPanel) + // 准星 this.crosshair = document.createElement('div') this.crosshair.className = 'hud-crosshair' this.container.appendChild(this.crosshair) + // 击杀信息(最近5条) this.killFeed = document.createElement('div') this.killFeed.className = 'hud-kill-feed' this.container.appendChild(this.killFeed) } + /** + * 显示HUD + */ show() { this.visible = true this.container.style.display = 'flex' } + /** + * 隐藏HUD + */ hide() { this.visible = false this.container.style.display = 'none' } + /** + * 更新血量显示 + * @param {number} health 血量值 0-100 + */ updateHealth(health) { const fill = this.healthBar.querySelector('.hud-health-fill') const text = this.healthBar.querySelector('.hud-health-text') const pct = Math.max(0, Math.min(100, health)) fill.style.width = pct + '%' + // 颜色根据血量变化:绿色>60,橙色>30,红色<=30 if (pct > 60) fill.style.backgroundColor = '#44ff44' else if (pct > 30) fill.style.backgroundColor = '#ffaa00' else fill.style.backgroundColor = '#ff4444' text.textContent = Math.ceil(pct) } + /** + * 更新武器显示 + * @param {number} currentIndex 当前武器索引 + * @param {Object} ammo 各武器弹药数 + */ updateWeapons(currentIndex, ammo) { const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE] for (let i = 0; i < this.weaponSlots.length; i++) { const slot = this.weaponSlots[i] + // 高亮当前武器 slot.slot.classList.toggle('hud-weapon-active', i === currentIndex) const weaponKey = weaponList[i] const currentAmmo = ammo[weaponKey] @@ -116,6 +150,10 @@ export class HUD { } } + /** + * 更新手雷蓄力显示 + * @param {number} percent 蓄力百分比 0-1 + */ updateGrenadeCharge(percent) { if (percent > 0) { this.grenadeChargeBar.style.display = 'block' @@ -126,6 +164,12 @@ export class HUD { } } + /** + * 更新游戏信息 + * @param {number} wave 当前波次 + * @param {number} score 分数 + * @param {number} time 游戏时间(秒) + */ updateInfo(wave, score, time) { this.waveText.textContent = 'Wave: ' + wave this.scoreText.textContent = 'Score: ' + score @@ -134,16 +178,22 @@ export class HUD { this.timeText.textContent = `Time: ${mins}:${secs.toString().padStart(2, '0')}` } + /** + * 添加击杀信息条目 + * @param {string} message 击杀信息文本 + */ addKillFeed(message) { const entry = document.createElement('div') entry.className = 'hud-kill-entry' entry.textContent = message this.killFeed.appendChild(entry) + // 4秒后自动移除 setTimeout(() => { if (entry.parentNode) entry.parentNode.removeChild(entry) }, 4000) + // 最多保留5条 while (this.killFeed.children.length > 5) { this.killFeed.removeChild(this.killFeed.firstChild) } } -} +} \ No newline at end of file diff --git a/frontend/src/ui/lobby.js b/frontend/src/ui/lobby.js index 9327e1b..9d6c664 100644 --- a/frontend/src/ui/lobby.js +++ b/frontend/src/ui/lobby.js @@ -1,32 +1,48 @@ +/** + * 大厅界面类 + * 处理房间创建、加入、玩家准备等大厅功能 + */ export class LobbyUI { constructor(container) { this.container = container + + // 事件回调 this.onCreateRoom = null this.onJoinRoom = null this.onReady = null this.onStartGame = null this.onLeaveRoom = null this.onRefreshRooms = null + + // 状态 this.currentRoom = null this.isHost = false this.playerId = null + this.render() } + /** + * 渲染大厅主界面 + * 显示房间列表、创建/加入按钮等 + */ render() { this.container.innerHTML = '' this.container.className = 'lobby-container' + // 标题 const title = document.createElement('h1') title.textContent = '🧟 Zombie Crisis 3' title.className = 'lobby-title' this.container.appendChild(title) + // 副标题 const subtitle = document.createElement('p') subtitle.textContent = 'Multiplayer Online Zombie Shooter' subtitle.className = 'lobby-subtitle' this.container.appendChild(subtitle) + // 玩家名称输入 const nameSection = document.createElement('div') nameSection.className = 'lobby-section' const nameLabel = document.createElement('label') @@ -41,9 +57,11 @@ export class LobbyUI { nameSection.appendChild(this.nameInput) this.container.appendChild(nameSection) + // 操作按钮区域 const actions = document.createElement('div') actions.className = 'lobby-actions' + // 创建房间按钮 const createBtn = document.createElement('button') createBtn.textContent = '🏠 Create Room' createBtn.className = 'lobby-btn lobby-btn-primary' @@ -52,6 +70,7 @@ export class LobbyUI { } actions.appendChild(createBtn) + // 刷新房间列表按钮 const refreshBtn = document.createElement('button') refreshBtn.textContent = '🔄 Refresh Rooms' refreshBtn.className = 'lobby-btn lobby-btn-secondary' @@ -62,6 +81,7 @@ export class LobbyUI { this.container.appendChild(actions) + // 房间列表区域 this.roomListSection = document.createElement('div') this.roomListSection.className = 'lobby-room-list' const roomTitle = document.createElement('h2') @@ -75,6 +95,10 @@ export class LobbyUI { this.container.appendChild(this.roomListSection) } + /** + * 更新房间列表 + * @param {Array} rooms 房间数据数组 + */ updateRoomList(rooms) { this.roomListContent.innerHTML = '' if (!rooms || rooms.length === 0) { @@ -85,6 +109,7 @@ export class LobbyUI { const roomCard = document.createElement('div') roomCard.className = 'lobby-room-card' + // 房间信息 const roomInfo = document.createElement('div') roomInfo.className = 'lobby-room-info' const roomName = document.createElement('span') @@ -96,6 +121,7 @@ export class LobbyUI { roomInfo.appendChild(roomName) roomInfo.appendChild(roomPlayers) + // 加入按钮(房间满时禁用) const joinBtn = document.createElement('button') joinBtn.textContent = 'Join' joinBtn.className = 'lobby-btn lobby-btn-small' @@ -110,6 +136,12 @@ export class LobbyUI { } } + /** + * 显示房间界面 + * @param {Object} roomData 房间数据 + * @param {boolean} isHost 是否为房主 + * @param {string} playerId 玩家ID + */ showRoom(roomData, isHost, playerId) { this.currentRoom = roomData this.isHost = isHost @@ -118,22 +150,27 @@ export class LobbyUI { this.container.innerHTML = '' this.container.className = 'room-container' + // 房间标题 const title = document.createElement('h2') title.textContent = '🧟 Room: ' + (roomData.hostName || 'Unknown') + "'s Room" title.className = 'room-title' this.container.appendChild(title) + // 玩家列表 const playerList = document.createElement('div') playerList.className = 'room-player-list' for (const player of roomData.players) { const playerCard = document.createElement('div') + // 当前玩家高亮显示 playerCard.className = 'room-player-card' + (player.id === playerId ? ' room-player-local' : '') + // 玩家名称(房主显示皇冠标记) const playerName = document.createElement('span') playerName.className = 'room-player-name' playerName.textContent = player.name + (player.id === roomData.hostId ? ' 👑' : '') + // 玩家状态 const playerStatus = document.createElement('span') playerStatus.className = 'room-player-status' if (player.id === roomData.hostId) { @@ -151,10 +188,12 @@ export class LobbyUI { this.container.appendChild(playerList) + // 操作按钮区域 const actions = document.createElement('div') actions.className = 'room-actions' if (!isHost) { + // 非房主:准备/取消准备按钮 const readyBtn = document.createElement('button') const myPlayer = roomData.players.find(p => p.id === playerId) readyBtn.textContent = myPlayer && myPlayer.ready ? '❌ Unready' : '✅ Ready' @@ -164,6 +203,7 @@ export class LobbyUI { } actions.appendChild(readyBtn) } else { + // 房主:开始游戏按钮(所有玩家准备好且至少1人才能开始) const allReady = roomData.players.filter(p => p.id !== roomData.hostId).every(p => p.ready) const startBtn = document.createElement('button') startBtn.textContent = '🚀 Start Game' @@ -175,6 +215,7 @@ export class LobbyUI { actions.appendChild(startBtn) } + // 离开房间按钮 const leaveBtn = document.createElement('button') leaveBtn.textContent = '🚪 Leave Room' leaveBtn.className = 'lobby-btn lobby-btn-danger' @@ -186,7 +227,10 @@ export class LobbyUI { this.container.appendChild(actions) } + /** + * 销毁大厅界面 + */ destroy() { this.container.innerHTML = '' } -} +} \ No newline at end of file diff --git a/frontend/src/ui/settings.js b/frontend/src/ui/settings.js index 3d6f5af..da91247 100644 --- a/frontend/src/ui/settings.js +++ b/frontend/src/ui/settings.js @@ -1,8 +1,14 @@ +/** + * 设置界面类 + * 管理游戏按键配置 + */ export class SettingsUI { constructor(container) { this.container = container this.visible = false this.onKeyChange = null + + // 默认按键绑定 this.defaultBindings = { moveUp: 'KeyW', moveDown: 'KeyS', @@ -16,31 +22,44 @@ export class SettingsUI { this.bindings = { ...this.defaultBindings } } + /** + * 显示设置界面 + * @param {Object} currentBindings 当前按键绑定 + */ show(currentBindings) { if (currentBindings) this.bindings = { ...currentBindings } this.visible = true this._render() } + /** + * 隐藏设置界面 + */ hide() { this.visible = false this.container.innerHTML = '' this.container.style.display = 'none' } + /** + * 渲染设置界面 + */ _render() { this.container.style.display = 'flex' this.container.className = 'settings-container' this.container.innerHTML = '' + // 设置面板 const panel = document.createElement('div') panel.className = 'settings-panel' + // 标题 const title = document.createElement('h2') title.textContent = '⚙ Key Bindings' title.className = 'settings-title' panel.appendChild(title) + // 按键标签映射 const labels = { moveUp: 'Move Up', moveDown: 'Move Down', @@ -52,6 +71,7 @@ export class SettingsUI { weapon4: 'Weapon 4 (Grenade)' } + // 生成每种操作的按键配置行 for (const [action, key] of Object.entries(this.bindings)) { const row = document.createElement('div') row.className = 'settings-row' @@ -60,6 +80,7 @@ export class SettingsUI { label.className = 'settings-label' label.textContent = labels[action] || action + // 按键捕获按钮 const keyBtn = document.createElement('button') keyBtn.className = 'settings-key-btn' keyBtn.textContent = this._formatKey(key) @@ -70,9 +91,11 @@ export class SettingsUI { panel.appendChild(row) } + // 按钮行 const btnRow = document.createElement('div') btnRow.className = 'settings-btn-row' + // 保存按钮 const saveBtn = document.createElement('button') saveBtn.textContent = 'Save' saveBtn.className = 'lobby-btn lobby-btn-primary' @@ -81,11 +104,13 @@ export class SettingsUI { this.hide() } + // 取消按钮 const cancelBtn = document.createElement('button') cancelBtn.textContent = 'Cancel' cancelBtn.className = 'lobby-btn lobby-btn-secondary' cancelBtn.onclick = () => this.hide() + // 重置默认按钮 const resetBtn = document.createElement('button') resetBtn.textContent = 'Reset Defaults' resetBtn.className = 'lobby-btn lobby-btn-danger' @@ -102,6 +127,11 @@ export class SettingsUI { this.container.appendChild(panel) } + /** + * 捕获按键输入 + * @param {string} action 操作类型 + * @param {HTMLElement} btn 按键按钮元素 + */ _captureKey(action, btn) { btn.textContent = 'Press a key...' btn.classList.add('settings-key-btn-capturing') @@ -109,6 +139,7 @@ export class SettingsUI { const handler = (e) => { e.preventDefault() e.stopPropagation() + // 更新绑定并显示格式化后的按键 this.bindings[action] = e.code btn.textContent = this._formatKey(e.code) btn.classList.remove('settings-key-btn-capturing') @@ -118,6 +149,11 @@ export class SettingsUI { window.addEventListener('keydown', handler) } + /** + * 格式化按键代码为可读文本 + * @param {string} code 按键代码 + * @returns {string} 格式化后的文本 + */ _formatKey(code) { return code .replace('Key', '') @@ -128,4 +164,4 @@ export class SettingsUI { .replace('Up', '↑') .replace('Down', '↓') } -} +} \ No newline at end of file diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 968b6ff..4da9d13 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -1,33 +1,48 @@ +// ========== 地图尺寸常量 ========== +// 地图网格大小(32x32格子) export const GRID_SIZE = 32 +// 每个格子的世界单位大小 export const CELL_SIZE = 1 +// 玩家碰撞半径 export const PLAYER_SIZE = 0.8 +// 僵尸碰撞半径 export const ZOMBIE_SIZE = 0.8 +// 墙壁厚度 export const WALL_SIZE = 1 +// 出生点标记 export const SPAWN_SIZE = 1 + +// ========== 游戏帧率常量 ========== +// 服务器每秒Tick数 export const TICK_RATE = 30 +// 每Tick间隔(毫秒) export const TICK_INTERVAL = 1000 / TICK_RATE +// ========== 武器枚举 ========== export const WEAPONS = { - PISTOL: 'pistol', - MACHINE_GUN: 'machine_gun', - SHOTGUN: 'shotgun', - GRENADE: 'grenade' + PISTOL: 'pistol', // 手枪 + MACHINE_GUN: 'machine_gun', // 机枪 + SHOTGUN: 'shotgun', // 霰弹枪 + GRENADE: 'grenade' // 手雷 } +// ========== 武器配置 ========== export const WEAPON_CONFIG = { + // 手枪配置 [WEAPONS.PISTOL]: { name: 'Pistol', - damage: 25, - fireRate: 400, - ammo: Infinity, - maxAmmo: Infinity, - speed: 20, - spread: 0, - pellets: 1, - range: 30, - auto: false, - chargeable: false + damage: 25, // 伤害 + fireRate: 400, // 射击间隔(毫秒) + ammo: Infinity, // 弹药量(无限) + maxAmmo: Infinity, // 最大弹药 + speed: 20, // 子弹速度 + spread: 0, // 散布角度 + pellets: 1, // 每次射击子弹数 + range: 30, // 射程 + auto: false, // 非自动武器 + chargeable: false // 不可蓄力 }, + // 机枪配置 [WEAPONS.MACHINE_GUN]: { name: 'Machine Gun', damage: 15, @@ -35,12 +50,13 @@ export const WEAPON_CONFIG = { ammo: 100, maxAmmo: 100, speed: 25, - spread: 0.05, + spread: 0.05, // 小幅散布 pellets: 1, range: 25, - auto: true, + auto: true, // 自动武器 chargeable: false }, + // 霰弹枪配置 [WEAPONS.SHOTGUN]: { name: 'Shotgun', damage: 20, @@ -48,12 +64,13 @@ export const WEAPON_CONFIG = { ammo: 20, maxAmmo: 20, speed: 18, - spread: 0.15, - pellets: 6, + spread: 0.15, // 大幅散布 + pellets: 6, // 每次6颗弹丸 range: 12, auto: false, chargeable: false }, + // 手雷配置 [WEAPONS.GRENADE]: { name: 'Grenade', damage: 100, @@ -65,45 +82,48 @@ export const WEAPON_CONFIG = { pellets: 1, range: 15, auto: false, - chargeable: true, - maxCharge: 2000, - explosionRadius: 3 + chargeable: true, // 可蓄力 + maxCharge: 2000, // 最大蓄力时间(毫秒) + explosionRadius: 3 // 爆炸半径 } } +// ========== 僵尸配置 ========== export const ZOMBIE_CONFIG = { - BASE_HEALTH: 100, - BASE_SPEED: 2, - DAMAGE: 10, - ATTACK_RATE: 1000, - SPAWN_INTERVAL_BASE: 3000, - SPAWN_INTERVAL_MIN: 800, - DIFFICULTY_INCREASE_INTERVAL: 30000, - HEALTH_INCREASE_PER_WAVE: 20, - SPEED_INCREASE_PER_WAVE: 0.1, - LOOT_DROP_CHANCE: 0.3 + BASE_HEALTH: 100, // 基础生命值 + BASE_SPEED: 2, // 基础移动速度 + DAMAGE: 10, // 攻击伤害 + ATTACK_RATE: 1000, // 攻击间隔(毫秒) + SPAWN_INTERVAL_BASE: 3000, // 基础生成间隔 + SPAWN_INTERVAL_MIN: 800, // 最小生成间隔 + DIFFICULTY_INCREASE_INTERVAL: 30000, // 难度增加间隔 + HEALTH_INCREASE_PER_WAVE: 20, // 每波生命值增加 + SPEED_INCREASE_PER_WAVE: 0.1, // 每波速度增加 + LOOT_DROP_CHANCE: 0.3 // 掉落物概率 } +// ========== 玩家配置 ========== export const PLAYER_CONFIG = { - MAX_HEALTH: 100, - SPEED: 5, - INVULNERABLE_TIME: 500 + MAX_HEALTH: 100, // 最大生命值 + SPEED: 5, // 移动速度 + INVULNERABLE_TIME: 500 // 无敌时间(受伤后) } +// ========== 网络消息类型 ========== export const MSG_TYPE = { - CREATE_ROOM: 'create_room', - JOIN_ROOM: 'join_room', - LEAVE_ROOM: 'leave_room', - ROOM_LIST: 'room_list', - ROOM_STATE: 'room_state', - READY: 'ready', - START_GAME: 'start_game', - GAME_STARTED: 'game_started', - PLAYER_INPUT: 'player_input', - GAME_STATE: 'game_state', - PLAYER_JOIN: 'player_join', - PLAYER_LEAVE: 'player_leave', - ERROR: 'error', - PING: 'ping', - PONG: 'pong' -} + CREATE_ROOM: 'create_room', // 创建房间 + JOIN_ROOM: 'join_room', // 加入房间 + LEAVE_ROOM: 'leave_room', // 离开房间 + ROOM_LIST: 'room_list', // 房间列表 + ROOM_STATE: 'room_state', // 房间状态 + READY: 'ready', // 玩家准备 + START_GAME: 'start_game', // 开始游戏 + GAME_STARTED: 'game_started', // 游戏开始 + PLAYER_INPUT: 'player_input', // 玩家输入 + GAME_STATE: 'game_state', // 游戏状态同步 + PLAYER_JOIN: 'player_join', // 玩家加入 + PLAYER_LEAVE: 'player_leave', // 玩家离开 + ERROR: 'error', // 错误消息 + PING: 'ping', // 心跳检测 + PONG: 'pong' // 心跳响应 +} \ No newline at end of file diff --git a/frontend/src/utils/grid.js b/frontend/src/utils/grid.js index 3bf8b59..5d44146 100644 --- a/frontend/src/utils/grid.js +++ b/frontend/src/utils/grid.js @@ -1,33 +1,61 @@ import { GRID_SIZE, CELL_SIZE, WALL_SIZE } from './constants.js' +/** + * 地图网格类 + * 管理地图数据、碰撞检测和路径搜索 + */ export class Grid { constructor(mapData) { - this.width = GRID_SIZE - this.height = GRID_SIZE - this.cells = [] + this.width = GRID_SIZE // 网格宽度 + this.height = GRID_SIZE // 网格高度 + this.cells = [] // 网格数据(二维数组) this.parseMap(mapData) } + /** + * 解析地图数据 + * 0 = 可行走,1 = 墙壁,2 = 玩家出生点,3 = 僵尸出生点 + * @param {Array} mapData 二维数组地图数据 + */ parseMap(mapData) { this.cells = [] for (let y = 0; y < this.height; y++) { this.cells[y] = [] for (let x = 0; x < this.width; x++) { + // 解析每个格子的类型 this.cells[y][x] = mapData[y] ? mapData[y][x] || 0 : 0 } } } + /** + * 检查指定网格是否为墙壁 + * @param {number} gridX 网格X坐标 + * @param {number} gridY 网格Y坐标 + * @returns {boolean} 是否为墙壁 + */ isWall(gridX, gridY) { if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return true return this.cells[gridY][gridX] === 1 } + /** + * 检查指定网格是否为玩家出生点 + * @param {number} gridX 网格X坐标 + * @param {number} gridY 网格Y坐标 + * @returns {boolean} 是否为出生点 + */ isSpawnPoint(gridX, gridY) { if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return false return this.cells[gridY][gridX] === 2 } + /** + * 世界坐标转换为网格坐标 + * @param {number} wx 世界X坐标 + * @param {number} wy 世界Y坐标 + * @returns {Object} {x, y} 网格坐标 + */ worldToGrid(wx, wy) { return { x: Math.floor(wx / CELL_SIZE), @@ -35,6 +63,12 @@ export class Grid { } } + /** + * 网格坐标转换为世界坐标(中心点) + * @param {number} gx 网格X坐标 + * @param {number} gy 网格Y坐标 + * @returns {Object} {x, y} 世界坐标 + */ gridToWorld(gx, gy) { return { x: gx * CELL_SIZE + CELL_SIZE / 2, @@ -42,8 +76,17 @@ export class Grid { } } + /** + * 检查世界坐标是否可通行 + * 检测实体四个角是否与墙壁碰撞 + * @param {number} wx 世界X坐标 + * @param {number} wy 世界Y坐标 + * @param {number} size 实体半径 + * @returns {boolean} 是否可通行 + */ isWalkable(wx, wy, size) { const half = size / 2 + // 检测四个角 const corners = [ { x: wx - half, y: wy - half }, { x: wx + half, y: wy - half }, @@ -57,6 +100,10 @@ export class Grid { return true } + /** + * 获取所有玩家出生点 + * @returns {Array} 出生点坐标数组 + */ getSpawnPoints() { const points = [] for (let y = 0; y < this.height; y++) { @@ -69,18 +116,31 @@ export class Grid { return points } + /** + * A*路径搜索算法 + * 寻找从起点到终点的最短路径 + * @param {number} startX 起点世界X坐标 + * @param {number} startY 起点世界Y坐标 + * @param {number} endX 终点世界X坐标 + * @param {number} endY 终点世界Y坐标 + * @returns {Array|null} 路径点数组,或null(无路径) + */ findPath(startX, startY, endX, endY) { const sg = this.worldToGrid(startX, startY) const eg = this.worldToGrid(endX, endY) + // 终点是墙壁则无解 if (this.isWall(eg.x, eg.y)) return null - const openSet = [] - const closedSet = new Set() - const cameFrom = new Map() + // A*算法数据结构 + const openSet = [] // 待处理节点 + const closedSet = new Set() // 已处理节点 + const cameFrom = new Map() // 路径记录 + // 曼哈顿距离启发函数 const heuristic = (ax, ay, bx, by) => Math.abs(ax - bx) + Math.abs(ay - by) + // 起点入队 const startKey = `${sg.x},${sg.y}` openSet.push({ x: sg.x, @@ -90,6 +150,7 @@ export class Grid { f: heuristic(sg.x, sg.y, eg.x, eg.y) }) + // 8个方向(4方向+4对角) const directions = [ { dx: 0, dy: -1 }, { dx: 0, dy: 1 }, @@ -104,12 +165,15 @@ export class Grid { let iterations = 0 const maxIterations = 2000 + // 主循环 while (openSet.length > 0 && iterations < maxIterations) { iterations++ + // 按f值排序,取最小 openSet.sort((a, b) => a.f - b.f) const current = openSet.shift() const currentKey = `${current.x},${current.y}` + // 到达终点,生成路径 if (current.x === eg.x && current.y === eg.y) { const path = [] let key = currentKey @@ -124,6 +188,7 @@ export class Grid { closedSet.add(currentKey) + // 遍历邻居 for (const dir of directions) { const nx = current.x + dir.dx const ny = current.y + dir.dy @@ -133,18 +198,22 @@ export class Grid { if (this.isWall(nx, ny)) continue if (nx < 0 || nx >= this.width || ny < 0 || ny >= this.height) continue + // 对角线移动时检查是否穿越角落 if (dir.dx !== 0 && dir.dy !== 0) { if (this.isWall(current.x + dir.dx, current.y) || this.isWall(current.x, current.y + dir.dy)) { continue } } + // 计算移动成本(对角线更长) const isDiagonal = dir.dx !== 0 && dir.dy !== 0 const moveCost = isDiagonal ? 1.414 : 1 const g = current.g + moveCost + // 检查是否已在openSet中 const existing = openSet.find(n => n.x === nx && n.y === ny) if (existing) { + // 如果新路径更短则更新 if (g < existing.g) { existing.g = g existing.f = g + existing.h @@ -158,15 +227,21 @@ export class Grid { } } - return null + return null // 超过最大迭代,无路径 } } +/** + * 生成默认地图 + * 带有边框墙壁和一些内部障碍物 + * @returns {Array} 二维数组地图数据 + */ export function generateDefaultMap() { const map = [] for (let y = 0; y < GRID_SIZE; y++) { map[y] = [] for (let x = 0; x < GRID_SIZE; x++) { + // 边框设为墙壁 if (x === 0 || x === GRID_SIZE - 1 || y === 0 || y === GRID_SIZE - 1) { map[y][x] = 1 } else { @@ -175,6 +250,7 @@ export function generateDefaultMap() { } } + // 墙壁段落列表 const wallSegments = [ { x1: 5, y1: 5, x2: 5, y2: 10 }, { x1: 10, y1: 3, x2: 15, y2: 3 }, @@ -192,14 +268,17 @@ export function generateDefaultMap() { { x1: 18, y1: 26, x2: 18, y2: 30 } ] + // 绘制墙壁 for (const seg of wallSegments) { if (seg.x1 === seg.x2) { + // 垂直墙 for (let y = seg.y1; y <= seg.y2; y++) { if (seg.x1 > 0 && seg.x1 < GRID_SIZE - 1 && y > 0 && y < GRID_SIZE - 1) { map[y][seg.x1] = 1 } } } else { + // 水平墙 for (let x = seg.x1; x <= seg.x2; x++) { if (x > 0 && x < GRID_SIZE - 1 && seg.y1 > 0 && seg.y1 < GRID_SIZE - 1) { map[seg.y1][x] = 1 @@ -208,6 +287,7 @@ export function generateDefaultMap() { } } + // 玩家出生点(四角) const spawnPoints = [ { x: 2, y: 2 }, { x: 29, y: 2 }, @@ -216,6 +296,7 @@ export function generateDefaultMap() { ] for (const sp of spawnPoints) { map[sp.y][sp.x] = 2 + // 出生点周围1格清除墙壁 for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const ny = sp.y + dy @@ -227,6 +308,7 @@ export function generateDefaultMap() { } } + // 僵尸出生点(中心和四边中点) const zombieSpawns = [ { x: 16, y: 2 }, { x: 2, y: 16 }, @@ -235,6 +317,7 @@ export function generateDefaultMap() { { x: 16, y: 16 } ] for (const sp of zombieSpawns) { + // 僵尸出生点周围清除墙壁 for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const ny = sp.y + dy @@ -247,4 +330,4 @@ export function generateDefaultMap() { } return map -} +} \ No newline at end of file diff --git a/frontend/src/utils/input.js b/frontend/src/utils/input.js index 24771c3..e584a83 100644 --- a/frontend/src/utils/input.js +++ b/frontend/src/utils/input.js @@ -1,7 +1,15 @@ +/** + * 输入管理器类 + * 处理键盘和鼠标输入,构建输入状态 + */ export class InputManager { constructor() { + // 按键状态映射 this.keys = {} + // 鼠标状态 this.mouse = { x: 0, y: 0, left: false, right: false } + + // 按键绑定配置 this.keyBindings = { moveUp: 'KeyW', moveDown: 'KeyS', @@ -12,11 +20,16 @@ export class InputManager { weapon3: 'Digit3', weapon4: 'Digit4' } + + // 待处理的输入序列(用于客户端预测) this.pendingActions = [] this.sequenceNumber = 0 + + // 事件回调 this.onKeyDown = null this.onKeyUp = null + // 绑定事件处理器(使用箭头函数保持this引用) this._onKeyDown = (e) => { this.keys[e.code] = true if (this.onKeyDown) this.onKeyDown(e) @@ -40,6 +53,10 @@ export class InputManager { this._onContextMenu = (e) => e.preventDefault() } + /** + * 附加事件监听器 + * 开始监听键盘鼠标事件 + */ attach() { window.addEventListener('keydown', this._onKeyDown) window.addEventListener('keyup', this._onKeyUp) @@ -49,6 +66,10 @@ export class InputManager { window.addEventListener('contextmenu', this._onContextMenu) } + /** + * 分离事件监听器 + * 停止监听键盘鼠标事件 + */ detach() { window.removeEventListener('keydown', this._onKeyDown) window.removeEventListener('keyup', this._onKeyUp) @@ -58,12 +79,18 @@ export class InputManager { window.removeEventListener('contextmenu', this._onContextMenu) } + /** + * 获取移动方向向量 + * @returns {Object} {dx, dy} 归一化的移动向量 + */ getMovement() { let dx = 0, dy = 0 + // WASD或方向键 if (this.keys[this.keyBindings.moveUp] || this.keys['ArrowUp']) dy -= 1 if (this.keys[this.keyBindings.moveDown] || this.keys['ArrowDown']) dy += 1 if (this.keys[this.keyBindings.moveLeft] || this.keys['ArrowLeft']) dx -= 1 if (this.keys[this.keyBindings.moveRight] || this.keys['ArrowRight']) dx += 1 + // 对角线移动时归一化 if (dx !== 0 && dy !== 0) { dx *= 0.7071 dy *= 0.7071 @@ -71,6 +98,10 @@ export class InputManager { return { dx, dy } } + /** + * 获取当前选择的武器 + * @returns {number} 武器索引(0-3),无选择返回-1 + */ getSelectedWeapon() { if (this.keys[this.keyBindings.weapon1]) return 0 if (this.keys[this.keyBindings.weapon2]) return 1 @@ -79,28 +110,42 @@ export class InputManager { return -1 } + /** + * 构建当前输入状态 + * @param {Object} mouseGroundPos 鼠标在世界坐标中的位置 + * @returns {Object} 输入状态对象 + */ buildInputState(mouseGroundPos) { const movement = this.getMovement() const weaponIdx = this.getSelectedWeapon() this.sequenceNumber++ return { - seq: this.sequenceNumber, - dx: movement.dx, - dy: movement.dy, - aimX: mouseGroundPos.x, - aimY: mouseGroundPos.y, - firing: this.mouse.left, - weaponIndex: weaponIdx + seq: this.sequenceNumber, // 序列号(用于客户端预测校正) + dx: movement.dx, // 移动方向X + dy: movement.dy, // 移动方向Y + aimX: mouseGroundPos.x, // 鼠标X坐标(世界) + aimY: mouseGroundPos.y, // 鼠标Y坐标(世界) + firing: this.mouse.left, // 是否开火 + weaponIndex: weaponIdx // 武器索引 } } + /** + * 更新按键绑定 + * @param {string} action 操作类型 + * @param {string} keyCode 按键代码 + */ updateKeyBinding(action, keyCode) { if (this.keyBindings.hasOwnProperty(action)) { this.keyBindings[action] = keyCode } } + /** + * 获取当前按键绑定 + * @returns {Object} 按键绑定副本 + */ getKeyBindings() { return { ...this.keyBindings } } -} +} \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 1d1fb50..dbb7301 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' export default defineConfig({ server: { + host: '0.0.0.0', port: 3000, proxy: { '/ws': {