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