Initial commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.backend.pid
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
62
backend/dependency-reduced-pom.xml
Normal file
62
backend/dependency-reduced-pom.xml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>com.zombie</groupId>
|
||||||
|
<artifactId>zombie-crisis-server</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<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>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>com.zombie.game.GameServerMain</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.30</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
92
backend/pom.xml
Normal file
92
backend/pom.xml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.zombie</groupId>
|
||||||
|
<artifactId>zombie-crisis-server</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.30</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.java-websocket</groupId>
|
||||||
|
<artifactId>Java-WebSocket</artifactId>
|
||||||
|
<version>1.5.4</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
<version>2.10.1</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<version>2.15.2</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-simple</artifactId>
|
||||||
|
<version>2.0.9</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<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>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>com.zombie.game.GameServerMain</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
34
backend/src/main/java/com/zombie/game/GameServerMain.java
Normal file
34
backend/src/main/java/com/zombie/game/GameServerMain.java
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package com.zombie.game;
|
||||||
|
|
||||||
|
import com.zombie.game.server.GameWebSocketServer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
public class GameServerMain {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GameServerMain.class);
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
int port = 8080;
|
||||||
|
if (args.length > 0) {
|
||||||
|
try {
|
||||||
|
port = Integer.parseInt(args[0]);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("Invalid port number, using default: 8080");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GameWebSocketServer server = new GameWebSocketServer(port);
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
logger.info("ZP2 Server started on port {}", port);
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||||
|
logger.info("Shutting down server...");
|
||||||
|
try {
|
||||||
|
server.stop();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error during shutdown", e);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
133
backend/src/main/java/com/zombie/game/ecs/ECSWorld.java
Normal file
133
backend/src/main/java/com/zombie/game/ecs/ECSWorld.java
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package com.zombie.game.ecs;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.components.*;
|
||||||
|
import com.zombie.game.model.Constants;
|
||||||
|
import com.zombie.game.model.GameMap;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** ECS world that manages all entities, components, and systems / ECS世界,管理所有实体、组件和系统 */
|
||||||
|
@Getter
|
||||||
|
public class ECSWorld {
|
||||||
|
private final Object lock = new Object();
|
||||||
|
private final GameMap map;
|
||||||
|
private int nextEntityId;
|
||||||
|
private final Set<Integer> entities = new LinkedHashSet<>();
|
||||||
|
private final Set<Integer> players = new LinkedHashSet<>();
|
||||||
|
private final Set<Integer> zombies = new LinkedHashSet<>();
|
||||||
|
private final Map<Integer, Position> positions = new HashMap<>();
|
||||||
|
private final Map<Integer, Health> healths = new HashMap<>();
|
||||||
|
private final Map<Integer, Collision> collisions = new HashMap<>();
|
||||||
|
private final Map<Integer, RenderInfo> renderInfos = new HashMap<>();
|
||||||
|
private final Map<Integer, PlayerInput> playerInputs = new HashMap<>();
|
||||||
|
private final Map<Integer, BlocksMovement> blocksMovements = new HashMap<>();
|
||||||
|
private final Map<Integer, MovementCost> movementCosts = new HashMap<>();
|
||||||
|
private final Map<Integer, ZombieAI> zombieAIs = new HashMap<>();
|
||||||
|
private final List<System> systems = new ArrayList<>();
|
||||||
|
private float gameTime;
|
||||||
|
private final Map<String, Integer> playerIdToEntity = new HashMap<>();
|
||||||
|
|
||||||
|
public ECSWorld(String mapFilePath) {
|
||||||
|
this.map = new GameMap(mapFilePath);
|
||||||
|
this.nextEntityId = 0;
|
||||||
|
this.gameTime = 0;
|
||||||
|
|
||||||
|
// Wire GameMap to ECS component maps
|
||||||
|
map.setComponentMaps(blocksMovements, movementCosts);
|
||||||
|
|
||||||
|
// Create wall entities from map data
|
||||||
|
createWallsFromMapData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create static wall and nut wall entities from map JSON data */
|
||||||
|
private void createWallsFromMapData() {
|
||||||
|
for (int[] pos : map.getStaticWallData()) {
|
||||||
|
createStaticWallEntity(pos[0], pos[1]);
|
||||||
|
}
|
||||||
|
for (int[] pos : map.getNutWallData()) {
|
||||||
|
createNutWallEntity(pos[0], pos[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a static wall entity (indestructible, blocks movement) */
|
||||||
|
public int createStaticWallEntity(int gx, int gy) {
|
||||||
|
int entityId = createEntity();
|
||||||
|
positions.put(entityId, new Position(gx + 0.5f, gy + 0.5f));
|
||||||
|
blocksMovements.put(entityId, new BlocksMovement());
|
||||||
|
map.registerWall(gx, gy, entityId);
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a nut wall entity (destructible, blocks movement with cost) */
|
||||||
|
public int createNutWallEntity(int gx, int gy) {
|
||||||
|
int entityId = createEntity();
|
||||||
|
positions.put(entityId, new Position(gx + 0.5f, gy + 0.5f));
|
||||||
|
healths.put(entityId, new Health(Constants.NUT_WALL_MAX_HEALTH));
|
||||||
|
blocksMovements.put(entityId, new BlocksMovement());
|
||||||
|
movementCosts.put(entityId, new MovementCost(Constants.NUT_WALL_MOVEMENT_COST));
|
||||||
|
map.registerWall(gx, gy, entityId);
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a zombie entity for testing pathfinding */
|
||||||
|
public int createZombieEntity(float x, float y) {
|
||||||
|
int entityId = createEntity();
|
||||||
|
positions.put(entityId, new Position(x, y));
|
||||||
|
healths.put(entityId, new Health(100));
|
||||||
|
collisions.put(entityId, new Collision(Constants.ZOMBIE_SIZE));
|
||||||
|
renderInfos.put(entityId, new RenderInfo(RenderInfo.EntityType.ZOMBIE));
|
||||||
|
zombieAIs.put(entityId, new ZombieAI());
|
||||||
|
zombies.add(entityId);
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int createEntity() {
|
||||||
|
int id = nextEntityId++;
|
||||||
|
entities.add(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void destroyEntity(int id) {
|
||||||
|
entities.remove(id);
|
||||||
|
players.remove(id);
|
||||||
|
zombies.remove(id);
|
||||||
|
positions.remove(id);
|
||||||
|
healths.remove(id);
|
||||||
|
collisions.remove(id);
|
||||||
|
renderInfos.remove(id);
|
||||||
|
playerInputs.remove(id);
|
||||||
|
blocksMovements.remove(id);
|
||||||
|
movementCosts.remove(id);
|
||||||
|
zombieAIs.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int createPlayerEntity(String playerId, String name, float x, float y) {
|
||||||
|
int entityId = createEntity();
|
||||||
|
positions.put(entityId, new Position(x, y));
|
||||||
|
healths.put(entityId, new Health(100));
|
||||||
|
collisions.put(entityId, new Collision(Constants.PLAYER_SIZE));
|
||||||
|
renderInfos.put(entityId, new RenderInfo(RenderInfo.EntityType.PLAYER));
|
||||||
|
playerInputs.put(entityId, new PlayerInput());
|
||||||
|
players.add(entityId);
|
||||||
|
playerIdToEntity.put(playerId, entityId);
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPlayerEntity(String playerId) {
|
||||||
|
return playerIdToEntity.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int[][] getMapData() { return map.getCells(); }
|
||||||
|
|
||||||
|
public void update(float dt) {
|
||||||
|
gameTime += dt;
|
||||||
|
for (System system : systems) {
|
||||||
|
system.update(dt, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addSystem(System system) {
|
||||||
|
systems.add(system);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/src/main/java/com/zombie/game/ecs/System.java
Normal file
7
backend/src/main/java/com/zombie/game/ecs/System.java
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package com.zombie.game.ecs;
|
||||||
|
|
||||||
|
/** Interface for ECS systems that process entities each frame / ECS系统接口,每帧处理实体 */
|
||||||
|
public interface System {
|
||||||
|
// Updates the system with delta time and world reference / 使用增量时间和世界引用更新系统
|
||||||
|
void update(float dt, ECSWorld world);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
/** Tag component: entity blocks movement on its grid cell / 标记组件:实体在其网格格子上阻挡移动 */
|
||||||
|
public class BlocksMovement {
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Component storing collision size for entity hitboxes / 存储实体碰撞体大小的组件 */
|
||||||
|
@Data
|
||||||
|
public class Collision {
|
||||||
|
// Collision radius/size for hitbox detection / 用于碰撞检测的碰撞体半径/大小
|
||||||
|
private float size;
|
||||||
|
|
||||||
|
// Constructor setting the collision size / 构造函数,设置碰撞体大小
|
||||||
|
public Collision(float size) {
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Component storing entity health points / 存储实体生命值的组件 */
|
||||||
|
@Data
|
||||||
|
public class Health {
|
||||||
|
// Current health value / 当前生命值
|
||||||
|
private float health;
|
||||||
|
// Maximum health value / 最大生命值
|
||||||
|
private float maxHealth;
|
||||||
|
|
||||||
|
// Constructor setting initial and max health / 构造函数,设置初始和最大生命值
|
||||||
|
public Health(float maxHealth) {
|
||||||
|
this.health = maxHealth;
|
||||||
|
this.maxHealth = maxHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks if the entity is still alive / 检查实体是否仍然存活
|
||||||
|
public boolean isAlive() {
|
||||||
|
return health > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resets health to maximum value / 将生命值重置为最大值
|
||||||
|
public void reset() {
|
||||||
|
this.health = maxHealth;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Component storing extra movement cost for flow field pathfinding / 存储流场寻路额外移动代价的组件 */
|
||||||
|
@Data
|
||||||
|
public class MovementCost {
|
||||||
|
/** Extra cost added on top of base (1.0 for straight, 1.414 for diagonal) / 基础代价之外的额外代价 */
|
||||||
|
private float cost;
|
||||||
|
|
||||||
|
public MovementCost(float cost) {
|
||||||
|
this.cost = cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Component storing player input state for movement and aiming / 存储玩家移动和瞄准输入状态的组件 */
|
||||||
|
@Data
|
||||||
|
public class PlayerInput {
|
||||||
|
// Movement direction X component / 移动方向X分量
|
||||||
|
private float dx;
|
||||||
|
// Movement direction Y component / 移动方向Y分量
|
||||||
|
private float dy;
|
||||||
|
// Aim target X coordinate / 瞄准目标X坐标
|
||||||
|
private float aimX;
|
||||||
|
// Aim target Y coordinate / 瞄准目标Y坐标
|
||||||
|
private float aimY;
|
||||||
|
// Input sequence number for ordering / 用于排序的输入序列号
|
||||||
|
private int seq;
|
||||||
|
|
||||||
|
// Default constructor initializing all values to zero / 默认构造函数,将所有值初始化为零
|
||||||
|
public PlayerInput() {
|
||||||
|
this.dx = 0;
|
||||||
|
this.dy = 0;
|
||||||
|
this.aimX = 0;
|
||||||
|
this.aimY = 0;
|
||||||
|
this.seq = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Component storing entity position and facing angle / 存储实体位置和朝向角度的组件 */
|
||||||
|
@Data
|
||||||
|
public class Position {
|
||||||
|
// X coordinate / X坐标
|
||||||
|
private float x;
|
||||||
|
// Y coordinate / Y坐标
|
||||||
|
private float y;
|
||||||
|
// Facing angle in radians / 朝向角度(弧度)
|
||||||
|
private float angle;
|
||||||
|
|
||||||
|
// Default constructor initializing to origin / 默认构造函数,初始化为原点
|
||||||
|
public Position() {
|
||||||
|
this.x = 0;
|
||||||
|
this.y = 0;
|
||||||
|
this.angle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructor with position only / 仅带位置的构造函数
|
||||||
|
public Position(float x, float y) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.angle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructor with position and angle / 带位置和角度的构造函数
|
||||||
|
public Position(float x, float y, float angle) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.angle = angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculates Euclidean distance to a point / 计算到某点的欧几里得距离
|
||||||
|
public float distanceTo(float px, float py) {
|
||||||
|
float dx = px - x;
|
||||||
|
float dy = py - y;
|
||||||
|
return (float) Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Component storing rendering information for an entity / 存储实体渲染信息的组件 */
|
||||||
|
@Data
|
||||||
|
public class RenderInfo {
|
||||||
|
// Enum defining possible entity types / 定义可能实体类型的枚举
|
||||||
|
public enum EntityType {
|
||||||
|
PLAYER,
|
||||||
|
ZOMBIE
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type of this entity / 此实体的类型
|
||||||
|
private EntityType type;
|
||||||
|
|
||||||
|
// Constructor setting the entity type / 构造函数,设置实体类型
|
||||||
|
public RenderInfo(EntityType type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.zombie.game.ecs.components;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/** Zombie AI component storing pathfinding target and state / 僵尸AI组件,存储寻路目标和状态 */
|
||||||
|
@Data
|
||||||
|
public class ZombieAI {
|
||||||
|
/** Current movement target X / 当前移动目标X */
|
||||||
|
private float targetX;
|
||||||
|
/** Current movement target Y / 当前移动目标Y */
|
||||||
|
private float targetY;
|
||||||
|
/** Whether zombie has an active target / 是否有活跃目标 */
|
||||||
|
private boolean hasTarget;
|
||||||
|
/** Reserved grid X to prevent overlap / 预留网格X防止重叠 */
|
||||||
|
private int reservedGridX = -1;
|
||||||
|
/** Reserved grid Y to prevent overlap / 预留网格Y防止重叠 */
|
||||||
|
private int reservedGridY = -1;
|
||||||
|
/** Whether zombie holds a grid reservation / 是否持有网格预留 */
|
||||||
|
private boolean reservation;
|
||||||
|
|
||||||
|
public ZombieAI() {
|
||||||
|
this.targetX = 0;
|
||||||
|
this.targetY = 0;
|
||||||
|
this.hasTarget = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
backend/src/main/java/com/zombie/game/model/Constants.java
Normal file
56
backend/src/main/java/com/zombie/game/model/Constants.java
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package com.zombie.game.model;
|
||||||
|
|
||||||
|
/** Game constants / 游戏常量 */
|
||||||
|
public class Constants {
|
||||||
|
// Grid cell size in pixels / 网格单元格大小(像素)
|
||||||
|
public static final int GRID_SIZE = 32;
|
||||||
|
// Tick rate per second / 每秒逻辑帧率
|
||||||
|
public static final int TICK_RATE = 30;
|
||||||
|
// Duration of one tick in seconds / 每帧持续时间(秒)
|
||||||
|
public static final float TICK_INTERVAL = 1.0f / TICK_RATE;
|
||||||
|
// Player collision size / 玩家碰撞体积
|
||||||
|
public static final float PLAYER_SIZE = 0.8f;
|
||||||
|
// Player movement speed / 玩家移动速度
|
||||||
|
public static final float PLAYER_SPEED = 5.0f;
|
||||||
|
// Zombie collision size / 僵尸碰撞体积
|
||||||
|
public static final float ZOMBIE_SIZE = 0.8f;
|
||||||
|
// Zombie movement speed / 僵尸移动速度
|
||||||
|
public static final float ZOMBIE_SPEED = 2.0f;
|
||||||
|
// Nut wall max health / 坚果墙最大生命值
|
||||||
|
public static final float NUT_WALL_MAX_HEALTH = 500.0f;
|
||||||
|
// Nut wall extra movement cost for flow field / 坚果墙流场额外移动代价
|
||||||
|
public static final float NUT_WALL_MOVEMENT_COST = 20.0f;
|
||||||
|
// Grid center threshold for retarget / 到达网格中心后重新选方向的阈值
|
||||||
|
public static final float GRID_CENTER_THRESHOLD = 0.15f;
|
||||||
|
// Zombie spawn interval in seconds / 僵尸刷新间隔(秒)
|
||||||
|
public static final float ZOMBIE_SPAWN_INTERVAL = 3.0f;
|
||||||
|
// Max zombie count / 最大僵尸数量
|
||||||
|
public static final int ZOMBIE_MAX_COUNT = 20;
|
||||||
|
|
||||||
|
// Create room message type / 创建房间消息类型
|
||||||
|
public static final String MSG_CREATE_ROOM = "create_room";
|
||||||
|
// Join room message type / 加入房间消息类型
|
||||||
|
public static final String MSG_JOIN_ROOM = "join_room";
|
||||||
|
// Leave room message type / 离开房间消息类型
|
||||||
|
public static final String MSG_LEAVE_ROOM = "leave_room";
|
||||||
|
// Room list message type / 房间列表消息类型
|
||||||
|
public static final String MSG_ROOM_LIST = "room_list";
|
||||||
|
// Room state message type / 房间状态消息类型
|
||||||
|
public static final String MSG_ROOM_STATE = "room_state";
|
||||||
|
// Ready state message type / 准备状态消息类型
|
||||||
|
public static final String MSG_READY = "ready";
|
||||||
|
// Start game message type / 开始游戏消息类型
|
||||||
|
public static final String MSG_START_GAME = "start_game";
|
||||||
|
// Game started notification / 游戏已开始通知
|
||||||
|
public static final String MSG_GAME_STARTED = "game_started";
|
||||||
|
// Player input message type / 玩家输入消息类型
|
||||||
|
public static final String MSG_PLAYER_INPUT = "player_input";
|
||||||
|
// Game state broadcast / 游戏状态广播
|
||||||
|
public static final String MSG_GAME_STATE = "game_state";
|
||||||
|
// Player joined notification / 玩家加入通知
|
||||||
|
public static final String MSG_PLAYER_JOIN = "player_join";
|
||||||
|
// Player left notification / 玩家离开通知
|
||||||
|
public static final String MSG_PLAYER_LEAVE = "player_leave";
|
||||||
|
// Error message type / 错误消息类型
|
||||||
|
public static final String MSG_ERROR = "error";
|
||||||
|
}
|
||||||
194
backend/src/main/java/com/zombie/game/model/FlowField.java
Normal file
194
backend/src/main/java/com/zombie/game/model/FlowField.java
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package com.zombie.game.model;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.components.BlocksMovement;
|
||||||
|
import com.zombie.game.ecs.components.MovementCost;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Flow field navigation using Dijkstra with ECS component-based wall queries / 基于ECS组件查询的Dijkstra流场导航 */
|
||||||
|
public class FlowField {
|
||||||
|
private final int width;
|
||||||
|
private final int height;
|
||||||
|
private float[][] distanceField;
|
||||||
|
private float[][] flowFieldX;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update flow field based on player positions and ECS wall components.
|
||||||
|
* @param wallGrid spatial index "x,y" -> entityId
|
||||||
|
* @param blocksMovements blocks-movement component map
|
||||||
|
* @param movementCosts movement-cost component map
|
||||||
|
* @param playerPositions list of [x, y] player positions
|
||||||
|
*/
|
||||||
|
public void update(Map<String, Integer> wallGrid,
|
||||||
|
Map<Integer, BlocksMovement> blocksMovements,
|
||||||
|
Map<Integer, MovementCost> movementCosts,
|
||||||
|
List<float[]> playerPositions) {
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
distanceField[y][x] = Float.MAX_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!isBlocked(wallGrid, blocksMovements, px, py)) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Diagonal: check corner blocking
|
||||||
|
if (dir[0] != 0 && dir[1] != 0) {
|
||||||
|
if (isBlocked(wallGrid, blocksMovements, cx + dir[0], cy) ||
|
||||||
|
isBlocked(wallGrid, blocksMovements, cx, cy + dir[1])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float moveCost = getMovementCost(wallGrid, blocksMovements, movementCosts, 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute flow directions
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
if (isBlocked(wallGrid, blocksMovements, 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(wallGrid, blocksMovements, nx, ny)) continue;
|
||||||
|
|
||||||
|
if (dir[0] != 0 && dir[1] != 0) {
|
||||||
|
if (isBlocked(wallGrid, blocksMovements, x + dir[0], y) ||
|
||||||
|
isBlocked(wallGrid, blocksMovements, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get flow direction at world position / 获取世界坐标处的流场方向 */
|
||||||
|
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]};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get distance to nearest player at world position / 获取世界坐标处到最近玩家的距离 */
|
||||||
|
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, Integer> wallGrid,
|
||||||
|
Map<Integer, BlocksMovement> blocksMovements,
|
||||||
|
int gx, int gy) {
|
||||||
|
if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true;
|
||||||
|
Integer entityId = wallGrid.get(gx + "," + gy);
|
||||||
|
if (entityId == null) return false;
|
||||||
|
return blocksMovements.containsKey(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float getMovementCost(Map<String, Integer> wallGrid,
|
||||||
|
Map<Integer, BlocksMovement> blocksMovements,
|
||||||
|
Map<Integer, MovementCost> movementCosts,
|
||||||
|
int gx, int gy, int[] dir) {
|
||||||
|
float baseCost = (dir[0] != 0 && dir[1] != 0) ? 1.414f : 1.0f;
|
||||||
|
Integer entityId = wallGrid.get(gx + "," + gy);
|
||||||
|
if (entityId == null) return baseCost;
|
||||||
|
// Completely blocked (no MovementCost but has BlocksMovement)
|
||||||
|
if (blocksMovements.containsKey(entityId) && !movementCosts.containsKey(entityId)) {
|
||||||
|
return Float.MAX_VALUE;
|
||||||
|
}
|
||||||
|
MovementCost mc = movementCosts.get(entityId);
|
||||||
|
if (mc != null) {
|
||||||
|
return baseCost + mc.getCost();
|
||||||
|
}
|
||||||
|
return baseCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
backend/src/main/java/com/zombie/game/model/GameMap.java
Normal file
197
backend/src/main/java/com/zombie/game/model/GameMap.java
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package com.zombie.game.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.zombie.game.ecs.components.BlocksMovement;
|
||||||
|
import com.zombie.game.ecs.components.MovementCost;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/** Game map: spatial index for wall entities, flow field, and spawn points / 游戏地图:墙体实体空间索引、流场和出生点 */
|
||||||
|
@Getter
|
||||||
|
public class GameMap {
|
||||||
|
private final int[][] cells;
|
||||||
|
private final int width;
|
||||||
|
private final int height;
|
||||||
|
/** Spatial index: "x,y" -> wall entity ID / 空间索引:"x,y" -> 墙体实体ID */
|
||||||
|
private final Map<String, Integer> wallGrid = new HashMap<>();
|
||||||
|
private final List<int[]> spawnPoints;
|
||||||
|
/** Raw static wall positions loaded from JSON / 从JSON加载的静态墙位置 */
|
||||||
|
private final List<int[]> staticWallData = new ArrayList<>();
|
||||||
|
/** Raw nut wall positions loaded from JSON / 从JSON加载的坚果墙位置 */
|
||||||
|
private final List<int[]> nutWallData = new ArrayList<>();
|
||||||
|
/** Flow field for zombie pathfinding / 僵尸寻路流场 */
|
||||||
|
private final FlowField flowField;
|
||||||
|
|
||||||
|
// References to ECS component maps, set by ECSWorld after construction
|
||||||
|
private Map<Integer, BlocksMovement> blocksMovements;
|
||||||
|
private Map<Integer, MovementCost> movementCosts;
|
||||||
|
|
||||||
|
public GameMap(String mapFilePath) {
|
||||||
|
JsonNode root = loadMapFile(mapFilePath);
|
||||||
|
this.width = root.get("width").asInt();
|
||||||
|
this.height = root.get("height").asInt();
|
||||||
|
this.cells = new int[height][width];
|
||||||
|
this.spawnPoints = new ArrayList<>();
|
||||||
|
this.flowField = new FlowField(width, height);
|
||||||
|
loadFromJson(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set ECS component map references (called by ECSWorld) */
|
||||||
|
public void setComponentMaps(Map<Integer, BlocksMovement> bm, Map<Integer, MovementCost> mc) {
|
||||||
|
this.blocksMovements = bm;
|
||||||
|
this.movementCosts = mc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a wall entity in the spatial index */
|
||||||
|
public void registerWall(int gx, int gy, int entityId) {
|
||||||
|
wallGrid.put(key(gx, gy), entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unregister a wall entity and invalidate flow field */
|
||||||
|
public void unregisterWall(int gx, int gy) {
|
||||||
|
wallGrid.remove(key(gx, gy));
|
||||||
|
flowField.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if grid cell blocks movement / 检查网格格子是否阻挡移动 */
|
||||||
|
public boolean isWall(int gx, int gy) {
|
||||||
|
if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true;
|
||||||
|
Integer entityId = wallGrid.get(key(gx, gy));
|
||||||
|
if (entityId == null) return false;
|
||||||
|
return blocksMovements != null && blocksMovements.containsKey(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if world-space area is walkable / 检查世界坐标区域是否可行走 */
|
||||||
|
public boolean isWalkable(float wx, float wy, float size) {
|
||||||
|
float half = size / 2;
|
||||||
|
float[][] corners = {
|
||||||
|
{wx - half, wy - half}, {wx + half, wy - half},
|
||||||
|
{wx - half, wy + half}, {wx + half, wy + half}
|
||||||
|
};
|
||||||
|
for (float[] c : corners) {
|
||||||
|
int gx = (int) Math.floor(c[0]);
|
||||||
|
int gy = (int) Math.floor(c[1]);
|
||||||
|
if (isWall(gx, gy)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update flow field with current player positions */
|
||||||
|
public void updateFlowField(List<float[]> playerPositions) {
|
||||||
|
flowField.update(wallGrid, blocksMovements, movementCosts, playerPositions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get flow direction at world position */
|
||||||
|
public float[] getFlowDirection(float wx, float wy) {
|
||||||
|
return flowField.getDirection(wx, wy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if flow field is valid */
|
||||||
|
public boolean isFlowFieldValid() {
|
||||||
|
return flowField.isValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get cell grid with wall info (for sync) / 获取包含墙壁信息的格子网格 */
|
||||||
|
public int[][] getCells() {
|
||||||
|
int[][] result = new int[height][width];
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
result[y][x] = cells[y][x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark wall cells from spatial index
|
||||||
|
for (Map.Entry<String, Integer> entry : wallGrid.entrySet()) {
|
||||||
|
String[] parts = entry.getKey().split(",");
|
||||||
|
int x = Integer.parseInt(parts[0]);
|
||||||
|
int y = Integer.parseInt(parts[1]);
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
Integer entityId = entry.getValue();
|
||||||
|
if (blocksMovements != null && blocksMovements.containsKey(entityId)) {
|
||||||
|
// Check if it's a nut wall (has MovementCost) or static wall
|
||||||
|
if (movementCosts != null && movementCosts.containsKey(entityId)) {
|
||||||
|
result[y][x] = 4; // nut wall
|
||||||
|
} else {
|
||||||
|
result[y][x] = 1; // static wall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get player spawn points */
|
||||||
|
public List<int[]> getSpawnPoints() {
|
||||||
|
return spawnPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private ---
|
||||||
|
|
||||||
|
private JsonNode loadMapFile(String mapFilePath) {
|
||||||
|
try {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
return mapper.readTree(new File(mapFilePath));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to load map file: " + mapFilePath, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadFromJson(JsonNode root) {
|
||||||
|
JsonNode wallsNode = root.get("walls");
|
||||||
|
if (wallsNode != null && wallsNode.isArray()) {
|
||||||
|
for (JsonNode wallNode : wallsNode) {
|
||||||
|
int x = (int) wallNode.get("x").asDouble();
|
||||||
|
int y = (int) wallNode.get("y").asDouble();
|
||||||
|
String type = wallNode.get("type").asText();
|
||||||
|
if (x < 0 || x >= width || y < 0 || y >= height) continue;
|
||||||
|
if ("static".equals(type)) {
|
||||||
|
staticWallData.add(new int[]{x, y});
|
||||||
|
} else if ("nut".equals(type)) {
|
||||||
|
nutWallData.add(new int[]{x, y});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode playerSpawns = root.get("playerSpawns");
|
||||||
|
if (playerSpawns != null && playerSpawns.isArray()) {
|
||||||
|
for (JsonNode spawn : playerSpawns) {
|
||||||
|
int x = spawn.get("x").asInt();
|
||||||
|
int y = spawn.get("y").asInt();
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
cells[y][x] = 2;
|
||||||
|
spawnPoints.add(new int[]{x, y});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode zombieSpawns = root.get("zombieSpawns");
|
||||||
|
if (zombieSpawns != null && zombieSpawns.isArray()) {
|
||||||
|
for (JsonNode spawn : zombieSpawns) {
|
||||||
|
int x = spawn.get("x").asInt();
|
||||||
|
int y = spawn.get("y").asInt();
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
cells[y][x] = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get zombie spawn points from cells / 从格子中获取僵尸出生点 */
|
||||||
|
public List<int[]> getZombieSpawnPoints() {
|
||||||
|
List<int[]> points = new ArrayList<>();
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
if (cells[y][x] == 3) {
|
||||||
|
points.add(new int[]{x, y});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(int x, int y) {
|
||||||
|
return x + "," + y;
|
||||||
|
}
|
||||||
|
}
|
||||||
144
backend/src/main/java/com/zombie/game/model/MapData.java
Normal file
144
backend/src/main/java/com/zombie/game/model/MapData.java
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package com.zombie.game.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
/** Serializable map data for storage or network transfer / 可序列化的地图数据,用于存储或网络传输 */
|
||||||
|
public class MapData {
|
||||||
|
// Map unique identifier / 地图唯一标识
|
||||||
|
private String id;
|
||||||
|
// Map display name / 地图显示名称
|
||||||
|
private String name;
|
||||||
|
// Map width in grid units / 地图宽度(网格单位)
|
||||||
|
private int width;
|
||||||
|
// Map height in grid units / 地图高度(网格单位)
|
||||||
|
private int height;
|
||||||
|
// List of wall data maps / 墙壁数据映射列表
|
||||||
|
private List<Map<String, Object>> walls;
|
||||||
|
// List of player spawn coordinates / 玩家出生点坐标列表
|
||||||
|
private List<Map<String, Integer>> playerSpawns;
|
||||||
|
// List of zombie spawn coordinates / 僵尸出生点坐标列表
|
||||||
|
private List<Map<String, Integer>> zombieSpawns;
|
||||||
|
|
||||||
|
// Default constructor / 默认构造函数
|
||||||
|
public MapData() {
|
||||||
|
this.walls = new ArrayList<>();
|
||||||
|
this.playerSpawns = new ArrayList<>();
|
||||||
|
this.zombieSpawns = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full constructor with all fields / 全字段构造函数
|
||||||
|
public MapData(String id, String name, int width, int height,
|
||||||
|
List<Map<String, Object>> walls,
|
||||||
|
List<Map<String, Integer>> playerSpawns,
|
||||||
|
List<Map<String, Integer>> zombieSpawns) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.walls = walls;
|
||||||
|
this.playerSpawns = playerSpawns;
|
||||||
|
this.zombieSpawns = zombieSpawns;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get map ID / 获取地图ID
|
||||||
|
public String getId() { return id; }
|
||||||
|
// Set map ID / 设置地图ID
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
// Get map name / 获取地图名称
|
||||||
|
public String getName() { return name; }
|
||||||
|
// Set map name / 设置地图名称
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
// Get map width / 获取地图宽度
|
||||||
|
public int getWidth() { return width; }
|
||||||
|
// Set map width / 设置地图宽度
|
||||||
|
public void setWidth(int width) { this.width = width; }
|
||||||
|
// Get map height / 获取地图高度
|
||||||
|
public int getHeight() { return height; }
|
||||||
|
// Set map height / 设置地图高度
|
||||||
|
public void setHeight(int height) { this.height = height; }
|
||||||
|
// Get walls list / 获取墙壁列表
|
||||||
|
public List<Map<String, Object>> getWalls() { return walls; }
|
||||||
|
// Set walls list / 设置墙壁列表
|
||||||
|
public void setWalls(List<Map<String, Object>> walls) { this.walls = walls; }
|
||||||
|
// Get player spawns / 获取玩家出生点
|
||||||
|
public List<Map<String, Integer>> getPlayerSpawns() { return playerSpawns; }
|
||||||
|
// Set player spawns / 设置玩家出生点
|
||||||
|
public void setPlayerSpawns(List<Map<String, Integer>> playerSpawns) { this.playerSpawns = playerSpawns; }
|
||||||
|
// Get zombie spawns / 获取僵尸出生点
|
||||||
|
public List<Map<String, Integer>> getZombieSpawns() { return zombieSpawns; }
|
||||||
|
// Set zombie spawns / 设置僵尸出生点
|
||||||
|
public void setZombieSpawns(List<Map<String, Integer>> zombieSpawns) { this.zombieSpawns = zombieSpawns; }
|
||||||
|
|
||||||
|
// Convert this map data to a 2D cell array / 将此地图数据转换为二维格子数组
|
||||||
|
public int[][] toCells() {
|
||||||
|
int[][] cells = new int[height][width];
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
cells[y][x] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark wall cells / 标记墙壁格子
|
||||||
|
for (Map<String, Object> wall : walls) {
|
||||||
|
int x = (Integer) wall.get("x");
|
||||||
|
int y = (Integer) wall.get("y");
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
cells[y][x] = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark player spawn cells / 标记玩家出生点格子
|
||||||
|
for (Map<String, Integer> spawn : playerSpawns) {
|
||||||
|
int x = spawn.get("x");
|
||||||
|
int y = spawn.get("y");
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
cells[y][x] = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mark zombie spawn cells / 标记僵尸出生点格子
|
||||||
|
for (Map<String, Integer> spawn : zombieSpawns) {
|
||||||
|
int x = spawn.get("x");
|
||||||
|
int y = spawn.get("y");
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
cells[y][x] = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create MapData from a GameMap instance / 从GameMap实例创建MapData
|
||||||
|
public static MapData fromGameMap(String id, String name, GameMap map) {
|
||||||
|
List<Map<String, Object>> walls = new ArrayList<>();
|
||||||
|
List<Map<String, Integer>> playerSpawns = new ArrayList<>();
|
||||||
|
List<Map<String, Integer>> zombieSpawns = new ArrayList<>();
|
||||||
|
|
||||||
|
// Convert static walls from raw data
|
||||||
|
for (int[] pos : map.getStaticWallData()) {
|
||||||
|
Map<String, Object> wallData = new HashMap<>();
|
||||||
|
wallData.put("x", pos[0]);
|
||||||
|
wallData.put("y", pos[1]);
|
||||||
|
wallData.put("type", "static");
|
||||||
|
walls.add(wallData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert nut walls from raw data
|
||||||
|
for (int[] pos : map.getNutWallData()) {
|
||||||
|
Map<String, Object> wallData = new HashMap<>();
|
||||||
|
wallData.put("x", pos[0]);
|
||||||
|
wallData.put("y", pos[1]);
|
||||||
|
wallData.put("type", "nut");
|
||||||
|
walls.add(wallData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert player spawn points
|
||||||
|
for (int[] spawn : map.getSpawnPoints()) {
|
||||||
|
Map<String, Integer> sp = new HashMap<>();
|
||||||
|
sp.put("x", spawn[0]);
|
||||||
|
sp.put("y", spawn[1]);
|
||||||
|
playerSpawns.add(sp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MapData(id, name, map.getWidth(), map.getHeight(), walls, playerSpawns, zombieSpawns);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/main/java/com/zombie/game/model/PlayerInfo.java
Normal file
26
backend/src/main/java/com/zombie/game/model/PlayerInfo.java
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package com.zombie.game.model;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/** Player information within a room / 房间内的玩家信息 */
|
||||||
|
@Getter
|
||||||
|
public class PlayerInfo {
|
||||||
|
// Player unique identifier / 玩家唯一标识
|
||||||
|
private final String id;
|
||||||
|
// Player display name / 玩家显示名称
|
||||||
|
private final String name;
|
||||||
|
// Whether the player is ready / 玩家是否已准备
|
||||||
|
private boolean ready;
|
||||||
|
|
||||||
|
// Create a new player info / 创建新的玩家信息
|
||||||
|
public PlayerInfo(String id, String name) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the ready state / 设置准备状态
|
||||||
|
public void setReady(boolean ready) {
|
||||||
|
this.ready = ready;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
backend/src/main/java/com/zombie/game/model/Room.java
Normal file
106
backend/src/main/java/com/zombie/game/model/Room.java
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package com.zombie.game.model;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import static com.zombie.game.model.Constants.*;
|
||||||
|
|
||||||
|
/** Game room holding players and game state / 游戏房间,管理玩家和游戏状态 */
|
||||||
|
@Getter
|
||||||
|
public class Room {
|
||||||
|
// Room unique identifier / 房间唯一标识
|
||||||
|
private String id;
|
||||||
|
// Host player ID / 房主玩家ID
|
||||||
|
private String hostId;
|
||||||
|
// Map of playerId to PlayerInfo / 玩家ID到玩家信息的映射
|
||||||
|
private Map<String, PlayerInfo> players;
|
||||||
|
// Whether the game has started / 游戏是否已开始
|
||||||
|
private boolean gameStarted;
|
||||||
|
// Maximum number of players allowed / 最大玩家数量
|
||||||
|
private final int maxPlayers = 4;
|
||||||
|
|
||||||
|
// Set game started flag / 设置游戏开始标志
|
||||||
|
public void setGameStarted(boolean started) { this.gameStarted = started; }
|
||||||
|
|
||||||
|
// Create a new room with the host player / 创建新房间并添加房主
|
||||||
|
public Room(String id, String hostId, String hostName) {
|
||||||
|
this.id = id;
|
||||||
|
this.hostId = hostId;
|
||||||
|
this.players = new LinkedHashMap<>();
|
||||||
|
players.put(hostId, new PlayerInfo(hostId, hostName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a player to the room / 添加玩家到房间
|
||||||
|
public boolean addPlayer(String playerId, String playerName) {
|
||||||
|
if (players.size() >= maxPlayers) return false;
|
||||||
|
if (players.containsKey(playerId)) return false;
|
||||||
|
players.put(playerId, new PlayerInfo(playerId, playerName));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a player from the room / 从房间移除玩家
|
||||||
|
public void removePlayer(String playerId) {
|
||||||
|
players.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get player by ID / 根据ID获取玩家
|
||||||
|
public PlayerInfo getPlayer(String playerId) {
|
||||||
|
return players.get(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all players / 获取所有玩家
|
||||||
|
public Collection<PlayerInfo> getPlayers() {
|
||||||
|
return players.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current player count / 获取当前玩家数量
|
||||||
|
public int getPlayerCount() {
|
||||||
|
return players.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a player is the host / 检查玩家是否为房主
|
||||||
|
public boolean isHost(String playerId) {
|
||||||
|
return hostId.equals(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all non-host players are ready / 检查所有非房主玩家是否已准备
|
||||||
|
public boolean allReady() {
|
||||||
|
for (PlayerInfo p : players.values()) {
|
||||||
|
if (!p.getId().equals(hostId) && !p.isReady()) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert room state to a map for a specific player / 将房间状态转换为指定玩家的Map
|
||||||
|
public Map<String, Object> toStateMap(String playerId) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("roomId", id);
|
||||||
|
map.put("hostId", hostId);
|
||||||
|
map.put("isHost", hostId.equals(playerId));
|
||||||
|
map.put("playerId", playerId);
|
||||||
|
|
||||||
|
// Build player list with index / 构建带索引的玩家列表
|
||||||
|
List<Map<String, Object>> playerList = new ArrayList<>();
|
||||||
|
int index = 0;
|
||||||
|
for (PlayerInfo p : players.values()) {
|
||||||
|
Map<String, Object> pm = new LinkedHashMap<>();
|
||||||
|
pm.put("id", p.getId());
|
||||||
|
pm.put("name", p.getName());
|
||||||
|
pm.put("ready", p.isReady());
|
||||||
|
pm.put("index", index++);
|
||||||
|
playerList.add(pm);
|
||||||
|
}
|
||||||
|
map.put("players", playerList);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to a lightweight room list entry / 转换为轻量级房间列表条目
|
||||||
|
public Map<String, Object> toRoomListMap() {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("id", id);
|
||||||
|
PlayerInfo host = players.get(hostId);
|
||||||
|
map.put("hostName", host != null ? host.getName() : "Unknown");
|
||||||
|
map.put("playerCount", players.size());
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/main/java/com/zombie/game/server/GameLoop.java
Normal file
73
backend/src/main/java/com/zombie/game/server/GameLoop.java
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package com.zombie.game.server;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game loop running at fixed tick rate for a single room / 以固定帧率运行的游戏循环,为单个房间服务
|
||||||
|
*/
|
||||||
|
public class GameLoop {
|
||||||
|
// Room ID this loop serves / 该循环服务的房间ID
|
||||||
|
private String roomId;
|
||||||
|
// The ECS world being updated / 正在更新的ECS世界
|
||||||
|
private ECSWorld world;
|
||||||
|
// Callback to broadcast state each tick / 每帧广播状态的回调
|
||||||
|
private GameService.GameStateBroadcast broadcaster;
|
||||||
|
// Whether the loop is currently running / 循环当前是否正在运行
|
||||||
|
private volatile boolean running;
|
||||||
|
// Target tick rate (30 ticks per second) / 目标帧率(每秒30帧)
|
||||||
|
private static final int TICK_RATE = 30;
|
||||||
|
// Interval between ticks in milliseconds / 帧间隔(毫秒)
|
||||||
|
private static final long TICK_INTERVAL_MS = 1000 / TICK_RATE;
|
||||||
|
// Interval between ticks in seconds / 帧间隔(秒)
|
||||||
|
private static final float TICK_INTERVAL_SEC = 1.0f / TICK_RATE;
|
||||||
|
// Scheduler for running the loop / 运行循环的调度器
|
||||||
|
private ScheduledExecutorService scheduler;
|
||||||
|
|
||||||
|
// Constructor: sets up the game loop for a room / 构造函数:为房间设置游戏循环
|
||||||
|
public GameLoop(String roomId, ECSWorld world, GameService.GameStateBroadcast broadcaster) {
|
||||||
|
this.roomId = roomId;
|
||||||
|
this.world = world;
|
||||||
|
this.broadcaster = broadcaster;
|
||||||
|
this.running = true;
|
||||||
|
// Create single-threaded daemon scheduler / 创建单线程守护调度器
|
||||||
|
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "GameLoop-" + roomId);
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the game loop / 启动游戏循环
|
||||||
|
public void start() {
|
||||||
|
scheduler.scheduleAtFixedRate(this::tick, 0, TICK_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single tick: update world and broadcast state / 单帧:更新世界并广播状态
|
||||||
|
private void tick() {
|
||||||
|
if (!running) return;
|
||||||
|
synchronized (world.getLock()) {
|
||||||
|
world.update(TICK_INTERVAL_SEC);
|
||||||
|
}
|
||||||
|
broadcaster.broadcast(roomId, world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the game loop / 停止游戏循环
|
||||||
|
public void stop() {
|
||||||
|
running = false;
|
||||||
|
if (scheduler != null && !scheduler.isShutdown()) {
|
||||||
|
scheduler.shutdown();
|
||||||
|
try {
|
||||||
|
// Force shutdown if graceful shutdown fails / 优雅关闭失败则强制关闭
|
||||||
|
if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
backend/src/main/java/com/zombie/game/server/GameService.java
Normal file
163
backend/src/main/java/com/zombie/game/server/GameService.java
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package com.zombie.game.server;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.ecs.components.*;
|
||||||
|
import com.zombie.game.model.*;
|
||||||
|
import com.zombie.game.systems.*;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core game service managing game lifecycle and state / 核心游戏服务,管理游戏生命周期和状态
|
||||||
|
*/
|
||||||
|
public class GameService {
|
||||||
|
// Logger for game events / 游戏事件日志记录器
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GameService.class);
|
||||||
|
|
||||||
|
// Active game worlds mapped by room ID / 活跃的游戏世界,以房间ID映射
|
||||||
|
private final Map<String, ECSWorld> activeGames = new ConcurrentHashMap<>();
|
||||||
|
// Game loops mapped by room ID / 游戏循环,以房间ID映射
|
||||||
|
private final Map<String, GameLoop> gameLoops = new ConcurrentHashMap<>();
|
||||||
|
// Callback for broadcasting game state / 广播游戏状态的回调
|
||||||
|
private final GameStateBroadcast broadcaster;
|
||||||
|
|
||||||
|
// Interface for broadcasting game state to clients / 向客户端广播游戏状态的接口
|
||||||
|
public interface GameStateBroadcast {
|
||||||
|
void broadcast(String roomId, ECSWorld world);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructor with broadcast callback / 带广播回调的构造函数
|
||||||
|
public GameService(GameStateBroadcast broadcaster) {
|
||||||
|
this.broadcaster = broadcaster;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new game for the given room / 为指定房间启动新游戏
|
||||||
|
public Map<String, Map<String, Object>> startGame(Room room) {
|
||||||
|
// Load map from system property or default / 从系统属性或默认路径加载地图
|
||||||
|
String mapPath = System.getProperty("map.path", "maps/d540209a.json");
|
||||||
|
ECSWorld world = new ECSWorld(mapPath);
|
||||||
|
|
||||||
|
// Add systems in order: input -> flow field -> zombie spawn -> zombie movement
|
||||||
|
world.addSystem(new PlayerInputSystem());
|
||||||
|
world.addSystem(new FlowFieldUpdateSystem());
|
||||||
|
world.addSystem(new ZombieSpawnSystem());
|
||||||
|
world.addSystem(new ZombieMovementSystem());
|
||||||
|
|
||||||
|
// Spawn players at map spawn points / 在地图出生点生成玩家
|
||||||
|
int index = 0;
|
||||||
|
List<int[]> spawnPoints = world.getMap().getSpawnPoints();
|
||||||
|
|
||||||
|
for (PlayerInfo playerInfo : room.getPlayers()) {
|
||||||
|
int[] sp = spawnPoints.get(index % spawnPoints.size());
|
||||||
|
float wx = sp[0] + 0.5f;
|
||||||
|
float wy = sp[1] + 0.5f;
|
||||||
|
world.createPlayerEntity(playerInfo.getId(), playerInfo.getName(), wx, wy);
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn test zombies at zombie spawn points / 在僵尸出生点生成测试僵尸
|
||||||
|
List<int[]> zombieSpawns = world.getMap().getZombieSpawnPoints();
|
||||||
|
for (int[] zsp : zombieSpawns) {
|
||||||
|
int zombieId = world.createZombieEntity(zsp[0] + 0.5f, zsp[1] + 0.5f);
|
||||||
|
logger.info("Initial zombie created: id={} at ({}, {})", zombieId, zsp[0], zsp[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeGames.put(room.getId(), world);
|
||||||
|
|
||||||
|
// Build initial data for each player / 为每位玩家构建初始数据
|
||||||
|
Map<String, Map<String, Object>> playerInitData = new LinkedHashMap<>();
|
||||||
|
for (PlayerInfo playerInfo : room.getPlayers()) {
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("playerId", playerInfo.getId());
|
||||||
|
data.put("mapData", serializeMapData(world.getMapData()));
|
||||||
|
|
||||||
|
// Build player list with positions / 构建包含位置的玩家列表
|
||||||
|
List<Map<String, Object>> playerList = new ArrayList<>();
|
||||||
|
int idx = 0;
|
||||||
|
for (PlayerInfo p : room.getPlayers()) {
|
||||||
|
Integer entityId = world.getPlayerEntity(p.getId());
|
||||||
|
Map<String, Object> pm = new LinkedHashMap<>();
|
||||||
|
pm.put("id", p.getId());
|
||||||
|
pm.put("name", p.getName());
|
||||||
|
if (entityId != null) {
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
if (pos != null) {
|
||||||
|
pm.put("x", pos.getX());
|
||||||
|
pm.put("y", pos.getY());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pm.put("index", idx++);
|
||||||
|
playerList.add(pm);
|
||||||
|
}
|
||||||
|
data.put("players", playerList);
|
||||||
|
playerInitData.put(playerInfo.getId(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create game loop (not started yet) / 创建游戏循环(尚未启动)
|
||||||
|
GameLoop loop = new GameLoop(room.getId(), world, broadcaster);
|
||||||
|
gameLoops.put(room.getId(), loop);
|
||||||
|
|
||||||
|
logger.info("Game initialized for room: {}", room.getId());
|
||||||
|
return playerInitData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the game loop for a room (call after sending game_started) / 启动房间的游戏循环(在发送 game_started 之后调用)
|
||||||
|
public void startGameLoop(String roomId) {
|
||||||
|
GameLoop loop = gameLoops.get(roomId);
|
||||||
|
if (loop != null) {
|
||||||
|
loop.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the game for a room / 停止指定房间的游戏
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process player input for movement and aiming / 处理玩家输入(移动和瞄准)
|
||||||
|
public void processPlayerInput(String roomId, String playerId,
|
||||||
|
float dx, float dy, float aimX, float aimY, int seq) {
|
||||||
|
ECSWorld world = activeGames.get(roomId);
|
||||||
|
if (world == null) return;
|
||||||
|
|
||||||
|
synchronized (world.getLock()) {
|
||||||
|
Integer entityId = world.getPlayerEntity(playerId);
|
||||||
|
if (entityId == null) return;
|
||||||
|
|
||||||
|
// Skip if player is dead / 如果玩家已死亡则跳过
|
||||||
|
Health health = world.getHealths().get(entityId);
|
||||||
|
if (health == null || !health.isAlive()) return;
|
||||||
|
|
||||||
|
// Apply input to player entity / 将输入应用到玩家实体
|
||||||
|
PlayerInput input = world.getPlayerInputs().get(entityId);
|
||||||
|
if (input == null) return;
|
||||||
|
|
||||||
|
input.setDx(dx);
|
||||||
|
input.setDy(dy);
|
||||||
|
input.setAimX(aimX);
|
||||||
|
input.setAimY(aimY);
|
||||||
|
input.setSeq(seq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert 2D int array to nested list for JSON serialization / 将二维整数数组转换为嵌套列表用于JSON序列化
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
package com.zombie.game.server;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.model.*;
|
||||||
|
import com.zombie.game.model.Constants;
|
||||||
|
import com.zombie.game.systems.StateSyncSystem;
|
||||||
|
import org.java_websocket.WebSocket;
|
||||||
|
import org.java_websocket.handshake.ClientHandshake;
|
||||||
|
import org.java_websocket.server.WebSocketServer;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main WebSocket server handling all client connections / 主WebSocket服务器,处理所有客户端连接
|
||||||
|
* Manages game rooms, player connections, and message routing / 管理游戏房间、玩家连接和消息路由
|
||||||
|
*/
|
||||||
|
public class GameWebSocketServer extends WebSocketServer {
|
||||||
|
// SLF4J logger for server events / SLF4J日志记录器,用于服务器事件
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GameWebSocketServer.class);
|
||||||
|
// JSON serializer/deserializer / JSON序列化/反序列化工具
|
||||||
|
private Gson gson;
|
||||||
|
// Room manager for creating and tracking game rooms / 房间管理器,用于创建和追踪游戏房间
|
||||||
|
private RoomManager roomManager;
|
||||||
|
// Game service for game logic / 游戏服务,用于游戏逻辑处理
|
||||||
|
private GameService gameService;
|
||||||
|
// Map connection to player ID / 连接映射到玩家ID
|
||||||
|
private Map<WebSocket, String> connectionToPlayer;
|
||||||
|
// Map player ID to connection / 玩家ID映射到连接
|
||||||
|
private Map<String, WebSocket> playerToConnection;
|
||||||
|
// Timer for broadcasting room list periodically / 定时器,用于定期广播房间列表
|
||||||
|
private Timer roomListTimer;
|
||||||
|
|
||||||
|
// Constructor: initializes server with given port / 构造函数:用指定端口初始化服务器
|
||||||
|
public GameWebSocketServer(int port) {
|
||||||
|
super(new InetSocketAddress(port));
|
||||||
|
this.gson = new Gson();
|
||||||
|
this.roomManager = new RoomManager();
|
||||||
|
this.connectionToPlayer = new ConcurrentHashMap<>();
|
||||||
|
this.playerToConnection = new ConcurrentHashMap<>();
|
||||||
|
this.gameService = new GameService(this::broadcastGameState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a new client connects / 新客户端连接时调用
|
||||||
|
@Override
|
||||||
|
public void onOpen(WebSocket conn, ClientHandshake handshake) {
|
||||||
|
logger.info("New connection: {}", conn.getRemoteSocketAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a client disconnects / 客户端断开连接时调用
|
||||||
|
@Override
|
||||||
|
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
|
||||||
|
logger.info("Connection closed: {}", conn.getRemoteSocketAddress());
|
||||||
|
// Remove player mapping and handle room leave / 移除玩家映射并处理离开房间
|
||||||
|
String playerId = connectionToPlayer.remove(conn);
|
||||||
|
if (playerId != null) {
|
||||||
|
playerToConnection.remove(playerId);
|
||||||
|
handleLeaveRoomByPlayerId(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a message is received from a client / 收到客户端消息时调用
|
||||||
|
@Override
|
||||||
|
public void onMessage(WebSocket conn, String message) {
|
||||||
|
try {
|
||||||
|
// Parse message type and data / 解析消息类型和数据
|
||||||
|
JsonObject msg = gson.fromJson(message, JsonObject.class);
|
||||||
|
String type = msg.get("type").getAsString();
|
||||||
|
JsonObject data = msg.has("data") ? msg.getAsJsonObject("data") : new JsonObject();
|
||||||
|
|
||||||
|
// Route message to appropriate handler / 将消息路由到对应的处理函数
|
||||||
|
switch (type) {
|
||||||
|
case Constants.MSG_CREATE_ROOM:
|
||||||
|
handleCreateRoom(conn, data);
|
||||||
|
break;
|
||||||
|
case Constants.MSG_JOIN_ROOM:
|
||||||
|
handleJoinRoom(conn, data);
|
||||||
|
break;
|
||||||
|
case Constants.MSG_LEAVE_ROOM:
|
||||||
|
handleLeaveRoomByConn(conn);
|
||||||
|
break;
|
||||||
|
case Constants.MSG_ROOM_LIST:
|
||||||
|
handleRoomList(conn);
|
||||||
|
break;
|
||||||
|
case Constants.MSG_READY:
|
||||||
|
handleReady(conn);
|
||||||
|
break;
|
||||||
|
case Constants.MSG_START_GAME:
|
||||||
|
handleStartGame(conn);
|
||||||
|
break;
|
||||||
|
case Constants.MSG_PLAYER_INPUT:
|
||||||
|
handlePlayerInput(conn, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error processing message from {}", conn.getRemoteSocketAddress(), e);
|
||||||
|
sendError(conn, "Invalid message format");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a WebSocket error occurs / WebSocket错误发生时调用
|
||||||
|
@Override
|
||||||
|
public void onError(WebSocket conn, Exception ex) {
|
||||||
|
logger.error("WebSocket error on {}", conn != null ? conn.getRemoteSocketAddress() : "null", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when the server starts successfully / 服务器成功启动时调用
|
||||||
|
@Override
|
||||||
|
public void onStart() {
|
||||||
|
logger.info("Game WebSocket Server started on port {}", getPort());
|
||||||
|
// Start periodic room list broadcast (every 2 seconds) / 启动定期房间列表广播(每2秒)
|
||||||
|
roomListTimer = new Timer(true);
|
||||||
|
roomListTimer.scheduleAtFixedRate(new TimerTask() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
broadcastRoomList();
|
||||||
|
}
|
||||||
|
}, 0, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle create room request / 处理创建房间请求
|
||||||
|
private void handleCreateRoom(WebSocket conn, JsonObject data) {
|
||||||
|
// Validate required fields / 验证必需字段
|
||||||
|
if (!MessageUtils.hasRequired(data, "playerName")) {
|
||||||
|
sendError(conn, "Missing playerName");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String playerName = MessageUtils.getString(data, "playerName");
|
||||||
|
// Generate unique IDs for player and room / 为玩家和房间生成唯一ID
|
||||||
|
String playerId = UUID.randomUUID().toString();
|
||||||
|
String roomId = UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
|
||||||
|
// Track connection mappings / 记录连接映射
|
||||||
|
connectionToPlayer.put(conn, playerId);
|
||||||
|
playerToConnection.put(playerId, conn);
|
||||||
|
|
||||||
|
// Create new room with player as host / 创建新房间,玩家作为房主
|
||||||
|
Room room = new Room(roomId, playerId, playerName);
|
||||||
|
roomManager.addRoom(roomId, room);
|
||||||
|
|
||||||
|
// Send room state back to creator / 向创建者发送房间状态
|
||||||
|
sendToConnection(conn, Constants.MSG_ROOM_STATE, room.toStateMap(playerId));
|
||||||
|
logger.info("Room created: {} by {}", roomId, playerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle join room request / 处理加入房间请求
|
||||||
|
private void handleJoinRoom(WebSocket conn, JsonObject data) {
|
||||||
|
// Validate required fields / 验证必需字段
|
||||||
|
if (!MessageUtils.hasRequired(data, "roomId", "playerName")) {
|
||||||
|
sendError(conn, "Missing roomId or playerName");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String roomId = MessageUtils.getString(data, "roomId");
|
||||||
|
String playerName = MessageUtils.getString(data, "playerName");
|
||||||
|
|
||||||
|
Room room = roomManager.getRoom(roomId);
|
||||||
|
if (room == null) {
|
||||||
|
sendError(conn, "Room not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if room is full (max 4 players) / 检查房间是否已满(最多4人)
|
||||||
|
if (room.getPlayerCount() >= 4) {
|
||||||
|
sendError(conn, "Room is full");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate player ID and track connection / 生成玩家ID并追踪连接
|
||||||
|
String playerId = UUID.randomUUID().toString();
|
||||||
|
connectionToPlayer.put(conn, playerId);
|
||||||
|
playerToConnection.put(playerId, conn);
|
||||||
|
|
||||||
|
// Add player to room / 将玩家添加到房间
|
||||||
|
room.addPlayer(playerId, playerName);
|
||||||
|
roomManager.joinRoom(roomId, playerId, playerName);
|
||||||
|
|
||||||
|
// Notify all players of updated room state / 通知所有玩家房间状态已更新
|
||||||
|
broadcastRoomState(room);
|
||||||
|
logger.info("Player {} joined room {}", playerName, roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle leave room by connection / 通过连接处理离开房间
|
||||||
|
private void handleLeaveRoomByConn(WebSocket conn) {
|
||||||
|
String playerId = connectionToPlayer.get(conn);
|
||||||
|
if (playerId == null) return;
|
||||||
|
handleLeaveRoomByPlayerId(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle leave room by player ID / 通过玩家ID处理离开房间
|
||||||
|
private void handleLeaveRoomByPlayerId(String playerId) {
|
||||||
|
Room room = roomManager.leaveRoom(playerId);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
Room updatedRoom = roomManager.getRoom(room.getId());
|
||||||
|
if (updatedRoom == null) {
|
||||||
|
// Room was deleted, stop the game / 房间被删除,停止游戏
|
||||||
|
gameService.stopGame(room.getId());
|
||||||
|
} else {
|
||||||
|
// Notify remaining players / 通知剩余玩家
|
||||||
|
broadcastRoomState(updatedRoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle room list request / 处理房间列表请求
|
||||||
|
private void handleRoomList(WebSocket conn) {
|
||||||
|
List<Map<String, Object>> roomList = new ArrayList<>();
|
||||||
|
for (Room room : roomManager.getAvailableRooms()) {
|
||||||
|
roomList.add(room.toRoomListMap());
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("rooms", roomList);
|
||||||
|
sendToConnection(conn, Constants.MSG_ROOM_LIST, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle player ready toggle / 处理玩家准备状态切换
|
||||||
|
private void handleReady(WebSocket conn) {
|
||||||
|
String playerId = connectionToPlayer.get(conn);
|
||||||
|
if (playerId == null) return;
|
||||||
|
|
||||||
|
Room room = roomManager.getRoomByPlayerId(playerId);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
PlayerInfo player = room.getPlayer(playerId);
|
||||||
|
if (player != null) {
|
||||||
|
// Toggle ready state / 切换准备状态
|
||||||
|
player.setReady(!player.isReady());
|
||||||
|
broadcastRoomState(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle start game request (host only) / 处理开始游戏请求(仅房主)
|
||||||
|
private void handleStartGame(WebSocket conn) {
|
||||||
|
String playerId = connectionToPlayer.get(conn);
|
||||||
|
if (playerId == null) {
|
||||||
|
sendError(conn, "Not in a room / 未在房间中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Room room = roomManager.getRoomByPlayerId(playerId);
|
||||||
|
if (room == null) {
|
||||||
|
sendError(conn, "Room not found / 房间不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.isHost(playerId)) {
|
||||||
|
sendError(conn, "Only host can start game / 只有房主可以开始游戏");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (room.isGameStarted()) {
|
||||||
|
sendError(conn, "Game already started / 游戏已经开始");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!room.allReady()) {
|
||||||
|
sendError(conn, "Not all players are ready / 不是所有玩家都已准备");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set game started flag after startGame succeeds / startGame 成功后再设置标志
|
||||||
|
try {
|
||||||
|
startGame(room);
|
||||||
|
room.setGameStarted(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to start game for room {}", room.getId(), e);
|
||||||
|
sendError(conn, "Failed to start game: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the game for a room / 为房间启动游戏
|
||||||
|
private void startGame(Room room) {
|
||||||
|
// Initialize game world and create player entities / 初始化游戏世界并创建玩家实体
|
||||||
|
Map<String, Map<String, Object>> playerInitData = gameService.startGame(room);
|
||||||
|
|
||||||
|
// Send initial game data to each player / 向每位玩家发送初始游戏数据
|
||||||
|
for (Map.Entry<String, Map<String, Object>> entry : playerInitData.entrySet()) {
|
||||||
|
WebSocket pConn = playerToConnection.get(entry.getKey());
|
||||||
|
if (pConn != null) {
|
||||||
|
sendToConnection(pConn, Constants.MSG_GAME_STARTED, entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start game loop after game_started is sent / game_started 发送完毕后再启动游戏循环
|
||||||
|
gameService.startGameLoop(room.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle player input (movement, aim) / 处理玩家输入(移动、瞄准)
|
||||||
|
private void handlePlayerInput(WebSocket conn, JsonObject data) {
|
||||||
|
String playerId = connectionToPlayer.get(conn);
|
||||||
|
if (playerId == null) return;
|
||||||
|
|
||||||
|
Room room = roomManager.getRoomByPlayerId(playerId);
|
||||||
|
if (room == null || !room.isGameStarted()) return;
|
||||||
|
|
||||||
|
// Extract input data / 提取输入数据
|
||||||
|
float dx = MessageUtils.getFloat(data, "dx", 0);
|
||||||
|
float dy = MessageUtils.getFloat(data, "dy", 0);
|
||||||
|
float aimX = MessageUtils.getFloat(data, "aimX", 0);
|
||||||
|
float aimY = MessageUtils.getFloat(data, "aimY", 0);
|
||||||
|
int seq = MessageUtils.getInt(data, "seq", 0);
|
||||||
|
|
||||||
|
gameService.processPlayerInput(room.getId(), playerId, dx, dy, aimX, aimY, seq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast game state to all players in a room / 向房间内所有玩家广播游戏状态
|
||||||
|
private void broadcastGameState(String roomId, ECSWorld world) {
|
||||||
|
Room room = roomManager.getRoom(roomId);
|
||||||
|
if (room == null) return;
|
||||||
|
|
||||||
|
StateSyncSystem syncSystem = new StateSyncSystem();
|
||||||
|
|
||||||
|
// Sync state under world lock / 在世界锁下同步状态
|
||||||
|
synchronized (world.getLock()) {
|
||||||
|
Map<String, Object> state = syncSystem.buildGameState(world);
|
||||||
|
for (PlayerInfo playerInfo : room.getPlayers()) {
|
||||||
|
WebSocket pConn = playerToConnection.get(playerInfo.getId());
|
||||||
|
if (pConn != null && pConn.isOpen()) {
|
||||||
|
sendToConnection(pConn, Constants.MSG_GAME_STATE, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast room state to all players in a room / 向房间内所有玩家广播房间状态
|
||||||
|
private void broadcastRoomState(Room room) {
|
||||||
|
for (PlayerInfo player : room.getPlayers()) {
|
||||||
|
WebSocket pConn = playerToConnection.get(player.getId());
|
||||||
|
if (pConn != null && pConn.isOpen()) {
|
||||||
|
sendToConnection(pConn, Constants.MSG_ROOM_STATE, room.toStateMap(player.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast room list to all unconnected clients / 向所有未连接的玩家广播房间列表
|
||||||
|
private void broadcastRoomList() {
|
||||||
|
List<Map<String, Object>> roomList = new ArrayList<>();
|
||||||
|
for (Room room : roomManager.getAvailableRooms()) {
|
||||||
|
roomList.add(room.toRoomListMap());
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("rooms", roomList);
|
||||||
|
|
||||||
|
// Only send to clients not in a room / 只发送给不在房间中的客户端
|
||||||
|
for (WebSocket conn : getConnections()) {
|
||||||
|
if (connectionToPlayer.get(conn) == null) {
|
||||||
|
sendToConnection(conn, Constants.MSG_ROOM_LIST, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message to a specific connection / 向指定连接发送消息
|
||||||
|
private void sendToConnection(WebSocket conn, String type, Object data) {
|
||||||
|
if (conn != null && conn.isOpen()) {
|
||||||
|
Map<String, Object> msg = new LinkedHashMap<>();
|
||||||
|
msg.put("type", type);
|
||||||
|
msg.put("data", data);
|
||||||
|
conn.send(gson.toJson(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send error message to a connection / 向连接发送错误消息
|
||||||
|
private void sendError(WebSocket conn, String message) {
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("message", message);
|
||||||
|
sendToConnection(conn, Constants.MSG_ERROR, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
backend/src/main/java/com/zombie/game/server/MapStorage.java
Normal file
136
backend/src/main/java/com/zombie/game/server/MapStorage.java
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package com.zombie.game.server;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.zombie.game.model.MapData;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage service for game maps (save, load, list, delete) / 地图存储服务(保存、加载、列出、删除)
|
||||||
|
*/
|
||||||
|
public class MapStorage {
|
||||||
|
// Logger for map operations / 地图操作日志记录器
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(MapStorage.class);
|
||||||
|
// Directory where map files are stored / 地图文件存储目录
|
||||||
|
private static final String MAPS_DIR = "maps";
|
||||||
|
// JSON serializer/deserializer / JSON序列化/反序列化工具
|
||||||
|
private final Gson gson;
|
||||||
|
// In-memory cache of loaded maps / 已加载地图的内存缓存
|
||||||
|
private final Map<String, MapData> cache;
|
||||||
|
|
||||||
|
// Constructor: initializes storage and ensures maps directory exists / 构造函数:初始化存储并确保地图目录存在
|
||||||
|
public MapStorage() {
|
||||||
|
this.gson = new Gson();
|
||||||
|
this.cache = new HashMap<>();
|
||||||
|
ensureMapsDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create maps directory if it doesn't exist / 如果地图目录不存在则创建
|
||||||
|
private void ensureMapsDir() {
|
||||||
|
Path mapsPath = Paths.get(MAPS_DIR);
|
||||||
|
if (!Files.exists(mapsPath)) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(mapsPath);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to create maps directory", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a map to disk and cache / 将地图保存到磁盘并缓存
|
||||||
|
public MapData save(MapData mapData) {
|
||||||
|
// Assign ID if missing / 如果缺少ID则生成
|
||||||
|
if (mapData.getId() == null || mapData.getId().isEmpty()) {
|
||||||
|
mapData.setId(UUID.randomUUID().toString().substring(0, 8));
|
||||||
|
}
|
||||||
|
// Set default name if missing / 如果缺少名称则使用默认值
|
||||||
|
if (mapData.getName() == null || mapData.getName().isEmpty()) {
|
||||||
|
mapData.setName("untitled");
|
||||||
|
}
|
||||||
|
|
||||||
|
String filename = mapData.getId() + ".json";
|
||||||
|
Path filePath = Paths.get(MAPS_DIR, filename);
|
||||||
|
|
||||||
|
try (Writer writer = Files.newBufferedWriter(filePath)) {
|
||||||
|
gson.toJson(mapData, writer);
|
||||||
|
cache.put(mapData.getId(), mapData);
|
||||||
|
logger.info("Map saved: {} ({})", mapData.getName(), mapData.getId());
|
||||||
|
return mapData;
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to save map: {}", mapData.getId(), e);
|
||||||
|
throw new RuntimeException("Failed to save map", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a map from cache or disk / 从缓存或磁盘加载地图
|
||||||
|
public MapData load(String id) {
|
||||||
|
// Check cache first / 先检查缓存
|
||||||
|
MapData cached = cache.get(id);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
String filename = id + ".json";
|
||||||
|
Path filePath = Paths.get(MAPS_DIR, filename);
|
||||||
|
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (Reader reader = Files.newBufferedReader(filePath)) {
|
||||||
|
MapData mapData = gson.fromJson(reader, MapData.class);
|
||||||
|
cache.put(id, mapData);
|
||||||
|
return mapData;
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to load map: {}", id, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all maps from disk / 列出磁盘上的所有地图
|
||||||
|
public List<MapData> listMaps() {
|
||||||
|
List<MapData> maps = new ArrayList<>();
|
||||||
|
Path mapsPath = Paths.get(MAPS_DIR);
|
||||||
|
|
||||||
|
if (!Files.exists(mapsPath)) {
|
||||||
|
return maps;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (DirectoryStream<Path> stream = Files.newDirectoryStream(mapsPath, "*.json")) {
|
||||||
|
for (Path file : stream) {
|
||||||
|
try (Reader reader = Files.newBufferedReader(file)) {
|
||||||
|
MapData mapData = gson.fromJson(reader, MapData.class);
|
||||||
|
maps.add(mapData);
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Failed to read map file: {}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to list maps", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return maps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a map from disk and cache / 从磁盘和缓存中删除地图
|
||||||
|
public boolean delete(String id) {
|
||||||
|
String filename = id + ".json";
|
||||||
|
Path filePath = Paths.get(MAPS_DIR, filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
boolean deleted = Files.deleteIfExists(filePath);
|
||||||
|
if (deleted) {
|
||||||
|
cache.remove(id);
|
||||||
|
logger.info("Map deleted: {}", id);
|
||||||
|
}
|
||||||
|
return deleted;
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to delete map: {}", id, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.zombie.game.server;
|
||||||
|
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for extracting values from JSON messages / 工具类,用于从JSON消息中提取值
|
||||||
|
*/
|
||||||
|
public class MessageUtils {
|
||||||
|
|
||||||
|
// Get string value from JSON with default / 从JSON获取字符串值,带默认值
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get string value from JSON (null default) / 从JSON获取字符串值(默认null)
|
||||||
|
public static String getString(JsonObject data, String key) {
|
||||||
|
return getString(data, key, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get float value from JSON with default / 从JSON获取浮点数值,带默认值
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get int value from JSON with default / 从JSON获取整数值,带默认值
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get boolean value from JSON with default / 从JSON获取布尔值,带默认值
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all required keys are present and non-null / 检查所有必需键是否存在且非空
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
backend/src/main/java/com/zombie/game/server/RoomManager.java
Normal file
103
backend/src/main/java/com/zombie/game/server/RoomManager.java
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package com.zombie.game.server;
|
||||||
|
|
||||||
|
import com.zombie.game.model.PlayerInfo;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room manager for tracking game rooms and player-room assignments / 房间管理器,用于追踪游戏房间和玩家房间分配
|
||||||
|
*/
|
||||||
|
public class RoomManager {
|
||||||
|
// All active rooms mapped by room ID / 所有活跃房间,以房间ID映射
|
||||||
|
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||||
|
// Player ID to room ID mapping / 玩家ID到房间ID的映射
|
||||||
|
private final Map<String, String> playerToRoom = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// Add a new room to the manager / 向管理器添加新房间
|
||||||
|
public void addRoom(String roomId, Room room) {
|
||||||
|
rooms.put(roomId, room);
|
||||||
|
// Map existing players in room to room ID / 将房间中现有玩家映射到房间ID
|
||||||
|
for (PlayerInfo player : room.getPlayers()) {
|
||||||
|
playerToRoom.put(player.getId(), roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a player to a room / 将玩家添加到房间
|
||||||
|
public boolean joinRoom(String roomId, String playerId, String playerName) {
|
||||||
|
Room room = rooms.get(roomId);
|
||||||
|
if (room == null) return false;
|
||||||
|
// Reject if room is full / 房间已满则拒绝
|
||||||
|
if (room.getPlayerCount() >= 4) return false;
|
||||||
|
|
||||||
|
boolean added = room.addPlayer(playerId, playerName);
|
||||||
|
if (added) {
|
||||||
|
playerToRoom.put(playerId, roomId);
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a player from their room / 从房间中移除玩家
|
||||||
|
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);
|
||||||
|
// Delete room if empty / 如果房间为空则删除
|
||||||
|
if (room.getPlayerCount() == 0) {
|
||||||
|
rooms.remove(roomId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get room by player ID / 通过玩家ID获取房间
|
||||||
|
public Room getRoomByPlayerId(String playerId) {
|
||||||
|
String roomId = playerToRoom.get(playerId);
|
||||||
|
if (roomId == null) return null;
|
||||||
|
return rooms.get(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get room by room ID / 通过房间ID获取房间
|
||||||
|
public Room getRoom(String roomId) {
|
||||||
|
return rooms.get(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all rooms / 获取所有房间
|
||||||
|
public Collection<Room> getAllRooms() {
|
||||||
|
return rooms.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rooms that haven't started a game yet / 获取尚未开始游戏的房间
|
||||||
|
public List<Room> getAvailableRooms() {
|
||||||
|
List<Room> available = new ArrayList<>();
|
||||||
|
for (Room room : rooms.values()) {
|
||||||
|
if (!room.isGameStarted()) {
|
||||||
|
available.add(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a room and all player mappings / 移除房间及其所有玩家映射
|
||||||
|
public void removeRoom(String roomId) {
|
||||||
|
Room room = rooms.remove(roomId);
|
||||||
|
if (room != null) {
|
||||||
|
for (PlayerInfo player : room.getPlayers()) {
|
||||||
|
playerToRoom.remove(player.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a player is in any room / 检查玩家是否在任意房间中
|
||||||
|
public boolean isPlayerInRoom(String playerId) {
|
||||||
|
return playerToRoom.containsKey(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.zombie.game.systems;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.ecs.System;
|
||||||
|
import com.zombie.game.ecs.components.Position;
|
||||||
|
import com.zombie.game.model.GameMap;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Updates flow field each frame based on player positions / 每帧根据玩家位置更新流场 */
|
||||||
|
public class FlowFieldUpdateSystem implements System {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(float dt, ECSWorld world) {
|
||||||
|
GameMap map = world.getMap();
|
||||||
|
|
||||||
|
List<float[]> playerPositions = new ArrayList<>();
|
||||||
|
for (int entityId : world.getPlayers()) {
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
if (pos != null) {
|
||||||
|
playerPositions.add(new float[]{pos.getX(), pos.getY()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerPositions.isEmpty()) {
|
||||||
|
map.updateFlowField(playerPositions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.zombie.game.systems;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.ecs.System;
|
||||||
|
import com.zombie.game.ecs.components.*;
|
||||||
|
|
||||||
|
import static com.zombie.game.model.Constants.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes player input to update positions and aim angles.
|
||||||
|
* 处理玩家输入以更新位置和瞄准角度。
|
||||||
|
*/
|
||||||
|
public class PlayerInputSystem implements System {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(float dt, ECSWorld world) {
|
||||||
|
// Iterate over all player entities / 遍历所有玩家实体
|
||||||
|
for (int entityId : world.getPlayers()) {
|
||||||
|
// Retrieve components for this entity / 获取该实体的组件
|
||||||
|
PlayerInput input = world.getPlayerInputs().get(entityId);
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
Health health = world.getHealths().get(entityId);
|
||||||
|
|
||||||
|
// Skip if any required component is missing or player is dead / 若缺少必要组件或玩家已死亡则跳过
|
||||||
|
if (input == null || pos == null || health == null || !health.isAlive()) continue;
|
||||||
|
|
||||||
|
// Calculate movement speed based on tick interval / 根据 tick 间隔计算移动速度
|
||||||
|
float speed = PLAYER_SPEED * TICK_INTERVAL;
|
||||||
|
// Compute new position from input direction / 根据输入方向计算新位置
|
||||||
|
float newX = pos.getX() + input.getDx() * speed;
|
||||||
|
float newY = pos.getY() + input.getDy() * speed;
|
||||||
|
|
||||||
|
// Update X position if walkable, clamped to grid bounds / 若可行走则更新 X 位置,限制在网格边界内
|
||||||
|
if (world.getMap().isWalkable(newX, pos.getY(), PLAYER_SIZE)) {
|
||||||
|
pos.setX(Math.max(0.5f, Math.min(GRID_SIZE - 0.5f, newX)));
|
||||||
|
}
|
||||||
|
// Update Y position if walkable, clamped to grid bounds / 若可行走则更新 Y 位置,限制在网格边界内
|
||||||
|
if (world.getMap().isWalkable(pos.getX(), newY, PLAYER_SIZE)) {
|
||||||
|
pos.setY(Math.max(0.5f, Math.min(GRID_SIZE - 0.5f, newY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update facing angle based on aim direction / 根据瞄准方向更新朝向角度
|
||||||
|
pos.setAngle((float) Math.atan2(input.getAimX() - pos.getX(), input.getAimY() - pos.getY()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.zombie.game.systems;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.ecs.System;
|
||||||
|
import com.zombie.game.ecs.components.*;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects and serializes game state for network synchronization.
|
||||||
|
* 收集并序列化游戏状态以进行网络同步。
|
||||||
|
*/
|
||||||
|
public class StateSyncSystem implements System {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a map representing the current game state including all players and zombies.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> buildGameState(ECSWorld world) {
|
||||||
|
Map<String, Object> state = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
// Players
|
||||||
|
List<Map<String, Object>> playerStates = new ArrayList<>();
|
||||||
|
for (int entityId : world.getPlayers()) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
Health health = world.getHealths().get(entityId);
|
||||||
|
|
||||||
|
String playerId = null;
|
||||||
|
for (var entry : world.getPlayerIdToEntity().entrySet()) {
|
||||||
|
if (entry.getValue() == entityId) {
|
||||||
|
playerId = entry.getKey();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.put("id", playerId);
|
||||||
|
if (pos != null) {
|
||||||
|
map.put("x", pos.getX());
|
||||||
|
map.put("y", pos.getY());
|
||||||
|
map.put("angle", pos.getAngle());
|
||||||
|
}
|
||||||
|
if (health != null) {
|
||||||
|
map.put("health", health.getHealth());
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerInput input = world.getPlayerInputs().get(entityId);
|
||||||
|
if (input != null) {
|
||||||
|
map.put("lastProcessedSeq", input.getSeq());
|
||||||
|
}
|
||||||
|
|
||||||
|
playerStates.add(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zombies (only alive)
|
||||||
|
List<Map<String, Object>> zombieStates = new ArrayList<>();
|
||||||
|
for (int entityId : world.getZombies()) {
|
||||||
|
Health health = world.getHealths().get(entityId);
|
||||||
|
if (health == null || !health.isAlive()) continue;
|
||||||
|
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
|
||||||
|
map.put("id", entityId);
|
||||||
|
if (pos != null) {
|
||||||
|
map.put("x", pos.getX());
|
||||||
|
map.put("y", pos.getY());
|
||||||
|
map.put("angle", pos.getAngle());
|
||||||
|
}
|
||||||
|
map.put("health", health.getHealth());
|
||||||
|
map.put("maxHealth", health.getMaxHealth());
|
||||||
|
|
||||||
|
zombieStates.add(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nut walls
|
||||||
|
List<Map<String, Object>> nutWallStates = new ArrayList<>();
|
||||||
|
for (Map.Entry<Integer, Health> entry : world.getHealths().entrySet()) {
|
||||||
|
int entityId = entry.getKey();
|
||||||
|
// Skip non-wall entities (players and zombies have health too)
|
||||||
|
if (world.getPlayers().contains(entityId) || world.getZombies().contains(entityId)) continue;
|
||||||
|
if (!world.getBlocksMovements().containsKey(entityId)) continue;
|
||||||
|
|
||||||
|
Health health = entry.getValue();
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
if (pos == null) continue;
|
||||||
|
|
||||||
|
Map<String, Object> wallState = new LinkedHashMap<>();
|
||||||
|
wallState.put("id", entityId);
|
||||||
|
wallState.put("x", (int) Math.floor(pos.getX()));
|
||||||
|
wallState.put("y", (int) Math.floor(pos.getY()));
|
||||||
|
wallState.put("health", health.getHealth());
|
||||||
|
wallState.put("maxHealth", health.getMaxHealth());
|
||||||
|
nutWallStates.add(wallState);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.put("players", playerStates);
|
||||||
|
state.put("zombies", zombieStates);
|
||||||
|
state.put("nutWalls", nutWallStates);
|
||||||
|
state.put("gameTime", world.getGameTime());
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(float dt, ECSWorld world) {
|
||||||
|
// No per-frame logic; state is built on demand
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package com.zombie.game.systems;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.ecs.System;
|
||||||
|
import com.zombie.game.ecs.components.*;
|
||||||
|
import com.zombie.game.model.GameMap;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import static com.zombie.game.model.Constants.*;
|
||||||
|
|
||||||
|
/** Zombie movement system using flow field pathfinding / 僵尸移动系统,使用流场寻路 */
|
||||||
|
public class ZombieMovementSystem implements System {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(float dt, ECSWorld world) {
|
||||||
|
GameMap map = world.getMap();
|
||||||
|
if (!map.isFlowFieldValid()) return;
|
||||||
|
|
||||||
|
List<Integer> sortedZombies = new ArrayList<>(world.getZombies());
|
||||||
|
Collections.sort(sortedZombies);
|
||||||
|
|
||||||
|
for (int entityId : sortedZombies) {
|
||||||
|
Position pos = world.getPositions().get(entityId);
|
||||||
|
Health health = world.getHealths().get(entityId);
|
||||||
|
ZombieAI ai = world.getZombieAIs().get(entityId);
|
||||||
|
|
||||||
|
if (pos == null || health == null || !health.isAlive()) continue;
|
||||||
|
if (ai == null) continue;
|
||||||
|
|
||||||
|
// Target selection: query flow field for next grid
|
||||||
|
float centerDist = Float.MAX_VALUE;
|
||||||
|
if (ai.isHasTarget()) {
|
||||||
|
float dx = ai.getTargetX() - pos.getX();
|
||||||
|
float dy = ai.getTargetY() - pos.getY();
|
||||||
|
centerDist = (float) Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ai.isHasTarget() || centerDist < GRID_CENTER_THRESHOLD) {
|
||||||
|
float[] flowDir = map.getFlowDirection(pos.getX(), pos.getY());
|
||||||
|
float dirX = flowDir[0];
|
||||||
|
float dirY = flowDir[1];
|
||||||
|
|
||||||
|
if (dirX == 0 && dirY == 0) continue;
|
||||||
|
|
||||||
|
int currentGridX = (int) Math.floor(pos.getX());
|
||||||
|
int currentGridY = (int) Math.floor(pos.getY());
|
||||||
|
int nextGridX = currentGridX + (int) Math.round(dirX);
|
||||||
|
int nextGridY = currentGridY + (int) Math.round(dirY);
|
||||||
|
|
||||||
|
// If next grid is wall, try to slide along single axis
|
||||||
|
if (map.isWall(nextGridX, nextGridY)) {
|
||||||
|
nextGridX = currentGridX + (int) Math.signum(dirX);
|
||||||
|
nextGridY = currentGridY + (int) Math.signum(dirY);
|
||||||
|
if (map.isWall(nextGridX, nextGridY)) {
|
||||||
|
if (!map.isWall(currentGridX + (int) Math.signum(dirX), currentGridY)) {
|
||||||
|
nextGridX = currentGridX + (int) Math.signum(dirX);
|
||||||
|
nextGridY = currentGridY;
|
||||||
|
} else if (!map.isWall(currentGridX, currentGridY + (int) Math.signum(dirY))) {
|
||||||
|
nextGridX = currentGridX;
|
||||||
|
nextGridY = currentGridY + (int) Math.signum(dirY);
|
||||||
|
} else {
|
||||||
|
ai.setReservation(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check grid occupation by other zombies
|
||||||
|
if (isGridOccupiedOrReserved(nextGridX, nextGridY, world, entityId)) {
|
||||||
|
int[] alt = findAlternativeDirection(currentGridX, currentGridY, dirX, dirY, map, world, entityId);
|
||||||
|
if (alt != null) {
|
||||||
|
nextGridX = alt[0];
|
||||||
|
nextGridY = alt[1];
|
||||||
|
} else {
|
||||||
|
ai.setReservation(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ai.setReservedGridX(nextGridX);
|
||||||
|
ai.setReservedGridY(nextGridY);
|
||||||
|
ai.setReservation(true);
|
||||||
|
ai.setTargetX(nextGridX + 0.5f);
|
||||||
|
ai.setTargetY(nextGridY + 0.5f);
|
||||||
|
ai.setHasTarget(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move toward target
|
||||||
|
float dx = ai.getTargetX() - pos.getX();
|
||||||
|
float dy = ai.getTargetY() - pos.getY();
|
||||||
|
float dist = (float) Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (dist < 0.01f) {
|
||||||
|
ai.setHasTarget(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
float moveDirX = dx / dist;
|
||||||
|
float moveDirY = dy / dist;
|
||||||
|
float speed = ZOMBIE_SPEED;
|
||||||
|
|
||||||
|
float moveX = moveDirX * speed * dt;
|
||||||
|
float moveY = moveDirY * speed * dt;
|
||||||
|
float newX = pos.getX() + moveX;
|
||||||
|
float newY = pos.getY() + moveY;
|
||||||
|
|
||||||
|
boolean canMoveX = map.isWalkable(newX, pos.getY(), ZOMBIE_SIZE);
|
||||||
|
boolean canMoveY = map.isWalkable(pos.getX(), newY, ZOMBIE_SIZE);
|
||||||
|
|
||||||
|
if (moveX != 0 && moveY != 0) {
|
||||||
|
// Diagonal: check corner blocking
|
||||||
|
int checkX = (int) Math.floor(newX);
|
||||||
|
int checkY = (int) Math.floor(newY);
|
||||||
|
int checkCurrentX = (int) Math.floor(pos.getX());
|
||||||
|
int checkCurrentY = (int) Math.floor(pos.getY());
|
||||||
|
|
||||||
|
boolean blockedByCorner = false;
|
||||||
|
if (checkX != checkCurrentX && checkY != checkCurrentY) {
|
||||||
|
boolean wallInX = map.isWall(checkX, checkCurrentY);
|
||||||
|
boolean wallInY = map.isWall(checkCurrentX, checkY);
|
||||||
|
if (wallInX || wallInY) {
|
||||||
|
blockedByCorner = true;
|
||||||
|
if (!wallInX && canMoveX) {
|
||||||
|
pos.setX(newX);
|
||||||
|
} else if (!wallInY && canMoveY) {
|
||||||
|
pos.setY(newY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blockedByCorner) {
|
||||||
|
boolean canMoveDiag = map.isWalkable(newX, newY, ZOMBIE_SIZE);
|
||||||
|
if (canMoveDiag) {
|
||||||
|
pos.setX(newX);
|
||||||
|
pos.setY(newY);
|
||||||
|
} else {
|
||||||
|
if (canMoveX) pos.setX(newX);
|
||||||
|
if (canMoveY) pos.setY(newY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (canMoveX) pos.setX(newX);
|
||||||
|
if (canMoveY) pos.setY(newY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zombie separation
|
||||||
|
for (int otherId : world.getZombies()) {
|
||||||
|
if (otherId == entityId) continue;
|
||||||
|
Health otherHealth = world.getHealths().get(otherId);
|
||||||
|
if (otherHealth == null || !otherHealth.isAlive()) continue;
|
||||||
|
Position otherPos = world.getPositions().get(otherId);
|
||||||
|
if (otherPos == null) continue;
|
||||||
|
|
||||||
|
float sepDx = pos.getX() - otherPos.getX();
|
||||||
|
float sepDy = pos.getY() - otherPos.getY();
|
||||||
|
float sepDist = (float) Math.sqrt(sepDx * sepDx + sepDy * sepDy);
|
||||||
|
|
||||||
|
if (sepDist < ZOMBIE_SIZE && sepDist > 0.01f) {
|
||||||
|
float overlap = ZOMBIE_SIZE - sepDist;
|
||||||
|
float pushX = (sepDx / sepDist) * overlap * 0.5f;
|
||||||
|
float pushY = (sepDy / sepDist) * overlap * 0.5f;
|
||||||
|
|
||||||
|
if (map.isWalkable(pos.getX() + pushX, pos.getY() + pushY, ZOMBIE_SIZE)) {
|
||||||
|
pos.setX(pos.getX() + pushX);
|
||||||
|
pos.setY(pos.getY() + pushY);
|
||||||
|
} else {
|
||||||
|
if (map.isWalkable(pos.getX() + pushX, pos.getY(), ZOMBIE_SIZE)) {
|
||||||
|
pos.setX(pos.getX() + pushX);
|
||||||
|
}
|
||||||
|
if (map.isWalkable(pos.getX(), pos.getY() + pushY, ZOMBIE_SIZE)) {
|
||||||
|
pos.setY(pos.getY() + pushY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update facing angle
|
||||||
|
if (moveDirX != 0 || moveDirY != 0) {
|
||||||
|
pos.setAngle((float) Math.atan2(moveDirX, moveDirY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGridOccupiedOrReserved(int gridX, int gridY, ECSWorld world, int selfId) {
|
||||||
|
for (int otherId : world.getZombies()) {
|
||||||
|
if (otherId == selfId) continue;
|
||||||
|
Health otherHealth = world.getHealths().get(otherId);
|
||||||
|
if (otherHealth == null || !otherHealth.isAlive()) continue;
|
||||||
|
|
||||||
|
Position otherPos = world.getPositions().get(otherId);
|
||||||
|
if (otherPos == null) continue;
|
||||||
|
|
||||||
|
int otherGridX = (int) Math.floor(otherPos.getX());
|
||||||
|
int otherGridY = (int) Math.floor(otherPos.getY());
|
||||||
|
if (otherGridX == gridX && otherGridY == gridY) return true;
|
||||||
|
|
||||||
|
ZombieAI otherAi = world.getZombieAIs().get(otherId);
|
||||||
|
if (otherAi != null && otherAi.isReservation() &&
|
||||||
|
otherAi.getReservedGridX() == gridX && otherAi.getReservedGridY() == gridY) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] findAlternativeDirection(int currentGridX, int currentGridY, float dirX, float dirY,
|
||||||
|
GameMap map, ECSWorld world, int selfId) {
|
||||||
|
int[][] allDirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}};
|
||||||
|
List<int[]> candidates = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int[] dir : allDirs) {
|
||||||
|
int nx = currentGridX + dir[0];
|
||||||
|
int ny = currentGridY + dir[1];
|
||||||
|
|
||||||
|
if (map.isWall(nx, ny)) continue;
|
||||||
|
|
||||||
|
if (dir[0] != 0 && dir[1] != 0) {
|
||||||
|
if (map.isWall(currentGridX + dir[0], currentGridY) ||
|
||||||
|
map.isWall(currentGridX, currentGridY + dir[1])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGridOccupiedOrReserved(nx, ny, world, selfId)) continue;
|
||||||
|
|
||||||
|
float dotProduct = dir[0] * dirX + dir[1] * dirY;
|
||||||
|
candidates.add(new int[]{nx, ny, (int) (dotProduct * 1000)});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) return null;
|
||||||
|
candidates.sort((a, b) -> b[2] - a[2]);
|
||||||
|
return new int[]{candidates.get(0)[0], candidates.get(0)[1]};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.zombie.game.systems;
|
||||||
|
|
||||||
|
import com.zombie.game.ecs.ECSWorld;
|
||||||
|
import com.zombie.game.ecs.System;
|
||||||
|
import com.zombie.game.ecs.components.Health;
|
||||||
|
import com.zombie.game.model.GameMap;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import static com.zombie.game.model.Constants.*;
|
||||||
|
|
||||||
|
/** Periodically spawns zombies at map spawn points / 定期在地图出生点生成僵尸 */
|
||||||
|
public class ZombieSpawnSystem implements System {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ZombieSpawnSystem.class);
|
||||||
|
private final Random random = new Random();
|
||||||
|
private float spawnTimer = 0;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void update(float dt, ECSWorld world) {
|
||||||
|
spawnTimer += dt;
|
||||||
|
if (spawnTimer < ZOMBIE_SPAWN_INTERVAL) return;
|
||||||
|
spawnTimer -= ZOMBIE_SPAWN_INTERVAL;
|
||||||
|
|
||||||
|
// Count alive zombies
|
||||||
|
int aliveCount = 0;
|
||||||
|
for (int entityId : world.getZombies()) {
|
||||||
|
Health health = world.getHealths().get(entityId);
|
||||||
|
if (health != null && health.isAlive()) {
|
||||||
|
aliveCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aliveCount >= ZOMBIE_MAX_COUNT) return;
|
||||||
|
|
||||||
|
// Pick a random spawn point
|
||||||
|
GameMap map = world.getMap();
|
||||||
|
List<int[]> spawns = map.getZombieSpawnPoints();
|
||||||
|
if (spawns.isEmpty()) return;
|
||||||
|
|
||||||
|
int[] sp = spawns.get(random.nextInt(spawns.size()));
|
||||||
|
int newZombie = world.createZombieEntity(sp[0] + 0.5f, sp[1] + 0.5f);
|
||||||
|
logger.info("Zombie spawned: id={} at ({}, {}), alive={}", newZombie, sp[0], sp[1], aliveCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/target/classes/com/zombie/game/GameServerMain.class
Normal file
BIN
backend/target/classes/com/zombie/game/GameServerMain.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/ecs/ECSWorld.class
Normal file
BIN
backend/target/classes/com/zombie/game/ecs/ECSWorld.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/ecs/System.class
Normal file
BIN
backend/target/classes/com/zombie/game/ecs/System.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/model/Constants.class
Normal file
BIN
backend/target/classes/com/zombie/game/model/Constants.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/model/FlowField.class
Normal file
BIN
backend/target/classes/com/zombie/game/model/FlowField.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/model/GameMap.class
Normal file
BIN
backend/target/classes/com/zombie/game/model/GameMap.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/model/MapData.class
Normal file
BIN
backend/target/classes/com/zombie/game/model/MapData.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/model/PlayerInfo.class
Normal file
BIN
backend/target/classes/com/zombie/game/model/PlayerInfo.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/model/Room.class
Normal file
BIN
backend/target/classes/com/zombie/game/model/Room.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/server/GameLoop.class
Normal file
BIN
backend/target/classes/com/zombie/game/server/GameLoop.class
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/server/GameService.class
Normal file
BIN
backend/target/classes/com/zombie/game/server/GameService.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/server/MapStorage.class
Normal file
BIN
backend/target/classes/com/zombie/game/server/MapStorage.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/server/MessageUtils.class
Normal file
BIN
backend/target/classes/com/zombie/game/server/MessageUtils.class
Normal file
Binary file not shown.
BIN
backend/target/classes/com/zombie/game/server/RoomManager.class
Normal file
BIN
backend/target/classes/com/zombie/game/server/RoomManager.class
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
backend/target/maven-archiver/pom.properties
Normal file
3
backend/target/maven-archiver/pom.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
artifactId=zombie-crisis-server
|
||||||
|
groupId=com.zombie
|
||||||
|
version=1.0.0
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
com/zombie/game/ecs/components/PlayerInput.class
|
||||||
|
com/zombie/game/model/MapData.class
|
||||||
|
com/zombie/game/model/GameMap.class
|
||||||
|
com/zombie/game/ecs/components/Collision.class
|
||||||
|
com/zombie/game/ecs/ECSWorld.class
|
||||||
|
com/zombie/game/server/GameWebSocketServer$1.class
|
||||||
|
com/zombie/game/ecs/components/Position.class
|
||||||
|
com/zombie/game/GameServerMain.class
|
||||||
|
com/zombie/game/server/MessageUtils.class
|
||||||
|
com/zombie/game/model/PlayerInfo.class
|
||||||
|
com/zombie/game/server/GameService$GameStateBroadcast.class
|
||||||
|
com/zombie/game/server/GameService.class
|
||||||
|
com/zombie/game/server/GameWebSocketServer.class
|
||||||
|
com/zombie/game/ecs/components/RenderInfo$EntityType.class
|
||||||
|
com/zombie/game/ecs/System.class
|
||||||
|
com/zombie/game/model/Room.class
|
||||||
|
com/zombie/game/server/MapStorage.class
|
||||||
|
com/zombie/game/systems/PlayerInputSystem.class
|
||||||
|
com/zombie/game/systems/StateSyncSystem.class
|
||||||
|
com/zombie/game/ecs/components/Health.class
|
||||||
|
com/zombie/game/model/Constants.class
|
||||||
|
com/zombie/game/ecs/components/RenderInfo.class
|
||||||
|
com/zombie/game/server/GameLoop.class
|
||||||
|
com/zombie/game/server/RoomManager.class
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/systems/ZombieSpawnSystem.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/ECSWorld.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/model/GameMap.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/systems/StateSyncSystem.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/server/GameLoop.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/ZombieAI.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/systems/PlayerInputSystem.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/Health.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/Collision.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/GameServerMain.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/model/Constants.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/server/RoomManager.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/server/GameService.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/model/MapData.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/systems/ZombieMovementSystem.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/BlocksMovement.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/server/MessageUtils.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/System.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/model/Room.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/server/MapStorage.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/PlayerInput.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/RenderInfo.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/model/PlayerInfo.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/MovementCost.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/ecs/components/Position.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/systems/FlowFieldUpdateSystem.java
|
||||||
|
/Users/wfz/workspace/zp2/backend/src/main/java/com/zombie/game/model/FlowField.java
|
||||||
BIN
backend/target/original-zombie-crisis-server-1.0.0.jar
Normal file
BIN
backend/target/original-zombie-crisis-server-1.0.0.jar
Normal file
Binary file not shown.
BIN
backend/target/zombie-crisis-server-1.0.0.jar
Normal file
BIN
backend/target/zombie-crisis-server-1.0.0.jar
Normal file
Binary file not shown.
597
docs/architecture.md
Normal file
597
docs/architecture.md
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
# ZP2 全局架构文档
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
ZP2 是一款多人在线俯视角 3D 游戏,玩家在 32x32 网格地图上移动、实时同步位置。本项目是 ZP1 的精简版,仅保留房间管理、地图加载、玩家移动和基础 3D 显示功能。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 | 版本 |
|
||||||
|
|------|------|------|
|
||||||
|
| 后端 | Java | 17 |
|
||||||
|
| 构建 | Maven | - |
|
||||||
|
| 通信 | Java-WebSocket | 1.5.4 |
|
||||||
|
| JSON | Gson + Jackson | 2.10.1 / 2.15.2 |
|
||||||
|
| 前端 | JavaScript (ES Module) | - |
|
||||||
|
| 渲染 | Three.js | ^0.170.0 |
|
||||||
|
| 构建 | Vite | ^6.0.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 系统架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Frontend (Browser) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ main.js │───→│ engine.js│───→│ scene.js │ │ client.js │ │
|
||||||
|
│ │ (App) │ │ (Loop/ │ │ (Three.js│ │(WebSocket)│ │
|
||||||
|
│ │ │ │ Predict) │ │ Render) │ │ │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ └─────┬────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||||
|
│ │ lobby.js │ │ input.js │ │ grid.js │ │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||||
|
└─────────────────────────────────────────────────────────┼────────┘
|
||||||
|
│ WebSocket
|
||||||
|
│ JSON
|
||||||
|
┌─────────────────────────────────────────────────────────┼────────┐
|
||||||
|
│ Backend (Java) │ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ GameWebSocketServer (8080) │←───────┘
|
||||||
|
│ │ onMessage 消息路由 → 房间CRUD / 玩家输入 / 状态广播 │
|
||||||
|
│ └──────┬───────────┬──────────────┬───────────────────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────┴───┐ ┌────┴─────┐ ┌────┴──────────┐
|
||||||
|
│ │RoomMgr │ │GameService│ │ GameLoop │
|
||||||
|
│ │(房间生命周期)│ │(游戏会话) │ │(30TPS固定帧) │
|
||||||
|
│ └──────────┘ └────┬─────┘ └───────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┴────────────────────────────┐
|
||||||
|
│ │ ECSWorld │
|
||||||
|
│ │ 实体(Entities) + 组件(组件) + 系统(Systems) │
|
||||||
|
│ │ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ │ PlayerInputSystem: 移动 + 碰撞 + 朝向 │ │
|
||||||
|
│ │ │ StateSyncSystem: 构建状态快照 → 广播 │ │
|
||||||
|
│ │ └──────────────────────────────────────────┘ │
|
||||||
|
│ └────────────────────────────────────────────────┘
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 后端架构
|
||||||
|
|
||||||
|
### 3.1 启动流程
|
||||||
|
|
||||||
|
```
|
||||||
|
GameServerMain.main()
|
||||||
|
├── 解析端口参数 (默认 8080)
|
||||||
|
├── 创建 GameWebSocketServer
|
||||||
|
├── server.start() 启动 WebSocket 监听
|
||||||
|
└── 注册 shutdown hook(优雅关闭)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 网络层 — GameWebSocketServer
|
||||||
|
|
||||||
|
核心职责:管理 WebSocket 连接、路由消息、广播状态。
|
||||||
|
|
||||||
|
**连接管理:**
|
||||||
|
- `connectionToPlayer`: WebSocket → playerId 映射
|
||||||
|
- `playerToConnection`: playerId → WebSocket 映射
|
||||||
|
|
||||||
|
**消息路由(onMessage):**
|
||||||
|
|
||||||
|
| 消息类型 | 处理方法 | 说明 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| `create_room` | `handleCreateRoom` | 创建房间,生成 playerId 和 roomId |
|
||||||
|
| `join_room` | `handleJoinRoom` | 加入房间,广播新状态 |
|
||||||
|
| `leave_room` | `handleLeaveRoomByConn` | 离开房间,清理游戏会话 |
|
||||||
|
| `room_list` | `handleRoomList` | 返回可用房间列表 |
|
||||||
|
| `ready` | `handleReady` | 切换玩家准备状态 |
|
||||||
|
| `start_game` | `handleStartGame` | 房主开始游戏(需全员准备) |
|
||||||
|
| `player_input` | `handlePlayerInput` | 转发玩家输入到 GameService |
|
||||||
|
|
||||||
|
**定时任务:**
|
||||||
|
- 每 2 秒广播房间列表给未加入房间的玩家
|
||||||
|
|
||||||
|
### 3.3 房间管理 — RoomManager
|
||||||
|
|
||||||
|
使用 `ConcurrentHashMap` 保证线程安全:
|
||||||
|
|
||||||
|
- `rooms`: roomId → Room 映射
|
||||||
|
- `playerToRoom`: playerId → roomId 映射
|
||||||
|
- `getAvailableRooms()`: 返回未开始游戏的房间
|
||||||
|
|
||||||
|
**Room 实体:**
|
||||||
|
- `id`: 房间唯一标识(UUID 前8位)
|
||||||
|
- `hostId`: 房主 playerId
|
||||||
|
- `players`: LinkedHashMap 保持加入顺序(最多4人)
|
||||||
|
- `gameStarted`: 游戏是否已开始
|
||||||
|
|
||||||
|
### 3.4 游戏服务 — GameService
|
||||||
|
|
||||||
|
**startGame(room) 流程:**
|
||||||
|
```
|
||||||
|
1. 加载地图文件 (maps/d540209a.json)
|
||||||
|
2. 创建 ECSWorld
|
||||||
|
3. 注册 PlayerInputSystem
|
||||||
|
4. 为每个玩家创建 PlayerEntity(分配到出生点)
|
||||||
|
5. 创建并启动 GameLoop
|
||||||
|
6. 返回每个玩家的初始化数据(mapData + players)
|
||||||
|
```
|
||||||
|
|
||||||
|
**processPlayerInput:**
|
||||||
|
- 查找玩家对应的 ECS 实体
|
||||||
|
- 更新 PlayerInput 组件(dx, dy, aimX, aimY, seq)
|
||||||
|
- 使用 `synchronized(world.lock)` 保证线程安全
|
||||||
|
|
||||||
|
### 3.5 游戏循环 — GameLoop
|
||||||
|
|
||||||
|
固定时间步长循环(30 TPS):
|
||||||
|
|
||||||
|
```
|
||||||
|
ScheduledExecutorService
|
||||||
|
└── scheduleAtFixedRate(tick, 0, 33ms)
|
||||||
|
├── synchronized(world.lock) { world.update(dt) }
|
||||||
|
└── broadcaster.broadcast(roomId, world)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 每 33ms(1000/30)执行一次 tick
|
||||||
|
- tick 内更新所有 ECS 系统
|
||||||
|
- 更新完成后广播游戏状态
|
||||||
|
|
||||||
|
### 3.6 ECS 架构
|
||||||
|
|
||||||
|
#### ECSWorld
|
||||||
|
|
||||||
|
核心容器,管理所有实体和组件:
|
||||||
|
|
||||||
|
| 组件映射 | 类型 | 说明 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `entities` | `LinkedHashSet<Integer>` | 所有活跃实体 |
|
||||||
|
| `players` | `LinkedHashSet<Integer>` | 玩家实体集合 |
|
||||||
|
| `positions` | `Map<Integer, Position>` | 位置组件 |
|
||||||
|
| `healths` | `Map<Integer, Health>` | 生命值组件 |
|
||||||
|
| `collisions` | `Map<Integer, Collision>` | 碰撞组件 |
|
||||||
|
| `renderInfos` | `Map<Integer, RenderInfo>` | 渲染组件 |
|
||||||
|
| `playerInputs` | `Map<Integer, PlayerInput>` | 输入组件 |
|
||||||
|
| `systems` | `List<System>` | 系统列表 |
|
||||||
|
| `playerIdToEntity` | `Map<String, Integer>` | 玩家ID到实体ID映射 |
|
||||||
|
|
||||||
|
**createPlayerEntity(playerId, name, x, y):**
|
||||||
|
```
|
||||||
|
1. createEntity() 获取新实体ID
|
||||||
|
2. 添加 Position(x, y)
|
||||||
|
3. 添加 Health(100)
|
||||||
|
4. 添加 Collision(PLAYER_SIZE=0.8)
|
||||||
|
5. 添加 RenderInfo(PLAYER)
|
||||||
|
6. 添加 PlayerInput()
|
||||||
|
7. 加入 players 集合
|
||||||
|
8. 建立 playerId → entityId 映射
|
||||||
|
```
|
||||||
|
|
||||||
|
#### System 接口
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface System {
|
||||||
|
void update(float dt, ECSWorld world);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PlayerInputSystem
|
||||||
|
|
||||||
|
每帧处理所有玩家输入:
|
||||||
|
|
||||||
|
```
|
||||||
|
对于每个玩家实体:
|
||||||
|
1. 获取 PlayerInput / Position / Health
|
||||||
|
2. 跳过死亡或缺少组件的实体
|
||||||
|
3. 计算移动:newX = x + dx * PLAYER_SPEED * TICK_INTERVAL
|
||||||
|
4. 逐轴碰撞检测:isWalkable(newX, y, PLAYER_SIZE)
|
||||||
|
5. 限制在地图边界 [0.5, GRID_SIZE-0.5]
|
||||||
|
6. 计算朝向:atan2(aimX - x, aimY - y)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### StateSyncSystem
|
||||||
|
|
||||||
|
构建游戏状态快照:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"id": "player-uuid",
|
||||||
|
"x": 12.5,
|
||||||
|
"y": 8.3,
|
||||||
|
"angle": 1.57,
|
||||||
|
"health": 100,
|
||||||
|
"lastProcessedSeq": 42
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gameTime": 12.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 地图系统
|
||||||
|
|
||||||
|
**GameMap** 从 JSON 文件加载:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"walls": [{"x": 5, "y": 10, "type": "static"}],
|
||||||
|
"playerSpawns": [{"x": 2, "y": 2}],
|
||||||
|
"zombieSpawns": [{"x": 15, "y": 15}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cells[y][x]`: 0=空地, 1=墙, 2=玩家出生点, 3=僵尸出生点
|
||||||
|
- `isWalkable(wx, wy, size)`: 检查实体四个角是否均可通行
|
||||||
|
- `isWall(gx, gy)`: 判断网格是否为墙或越界
|
||||||
|
|
||||||
|
**MapStorage**: 基于文件的地图持久化(save/load/list/delete)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端架构
|
||||||
|
|
||||||
|
### 4.1 应用入口 — main.js
|
||||||
|
|
||||||
|
```
|
||||||
|
App 构造函数
|
||||||
|
├── 创建 lobbyEl 和 gameCanvasEl
|
||||||
|
├── 实例化 LobbyUI
|
||||||
|
└── _setupLobby() 注册回调
|
||||||
|
```
|
||||||
|
|
||||||
|
**视图切换:**
|
||||||
|
- 默认显示 lobby,隐藏 canvas
|
||||||
|
- `game_started` 消息后:隐藏 lobby,显示 canvas
|
||||||
|
|
||||||
|
**连接管理(_ensureConnection):**
|
||||||
|
```
|
||||||
|
if (!engine):
|
||||||
|
创建 GameEngine → connect(WS_URL) → 注册网络处理器
|
||||||
|
else if (!connected):
|
||||||
|
重连
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 游戏引擎 — engine.js
|
||||||
|
|
||||||
|
#### 核心数据结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
this.players = Map<playerId, {
|
||||||
|
id, name, x, y, angle, health, color, isLocal
|
||||||
|
}>
|
||||||
|
this.pendingInputs = [] // 待确认输入队列
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 游戏循环(固定时间步长)
|
||||||
|
|
||||||
|
```
|
||||||
|
requestAnimationFrame(_loop)
|
||||||
|
delta = now - lastTick
|
||||||
|
accumulator += delta
|
||||||
|
while (accumulator >= TICK_INTERVAL):
|
||||||
|
_tick()
|
||||||
|
accumulator -= TICK_INTERVAL
|
||||||
|
updateCamera(localPlayer)
|
||||||
|
scene.render()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 客户端预测(_applyLocalPrediction)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取输入状态 (dx, dy)
|
||||||
|
2. 计算新位置: newX = x + dx * SPEED * dt
|
||||||
|
3. 逐轴碰撞检测
|
||||||
|
4. 限制在地图范围内 [0.5, 31.5]
|
||||||
|
5. 计算朝向: atan2(aimX - x, aimY - y)
|
||||||
|
6. 更新场景中的玩家模型
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 服务器校正(_reconcileLocalPlayer)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用服务器权威状态覆盖本地位置
|
||||||
|
2. 移除 seq <= lastProcessedSeq 的输入(已确认)
|
||||||
|
3. 对剩余未确认输入重新执行预测
|
||||||
|
4. 更新场景
|
||||||
|
```
|
||||||
|
|
||||||
|
这是经典的 **客户端预测 + 服务器校正** 模式:
|
||||||
|
- 客户端立即响应输入(零延迟手感)
|
||||||
|
- 服务器每帧广播权威状态
|
||||||
|
- 客户端用 seq 号匹配已处理输入,重新模拟未确认输入
|
||||||
|
|
||||||
|
### 4.3 3D 渲染 — scene.js
|
||||||
|
|
||||||
|
**场景设置:**
|
||||||
|
- 透视相机 (FOV=45°, 偏移量 (0, 25, 18))
|
||||||
|
- 环境光 + 方向光(带阴影)+ 点光源
|
||||||
|
- 窗口大小自适应
|
||||||
|
|
||||||
|
**地图渲染(buildMap):**
|
||||||
|
- 地板: 32x32 Plane (深灰色)
|
||||||
|
- 墙壁: BoxGeometry(1, 1.5, 1) (蓝灰色)
|
||||||
|
- 出生点: BoxGeometry(1, 0.1, 1) (绿色半透明)
|
||||||
|
|
||||||
|
**玩家模型(createPlayerModel):**
|
||||||
|
```
|
||||||
|
Group
|
||||||
|
├── 身体: Cylinder(半径 0.4, 高 0.8)
|
||||||
|
├── 头部: Sphere(半径 0.2, 肤色)
|
||||||
|
└── 武器: Box(0.08, 0.08, 0.5, 深色)
|
||||||
|
```
|
||||||
|
|
||||||
|
**鼠标射线投射(getMouseGroundPos):**
|
||||||
|
```
|
||||||
|
鼠标屏幕坐标 → NDC → Raycaster → 与 Y=0 平面相交 → 地面世界坐标
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 网络客户端 — client.js
|
||||||
|
|
||||||
|
WebSocket 封装,提供:
|
||||||
|
- 事件注册:`on(type, handler)`
|
||||||
|
- 消息发送:`send(type, data)`
|
||||||
|
- 便捷方法:`createRoom`, `joinRoom`, `sendInput` 等
|
||||||
|
|
||||||
|
消息格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "player_input",
|
||||||
|
"data": {"dx": 1, "dy": 0, "aimX": 10, "aimY": 5, "seq": 42}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 输入管理 — input.js
|
||||||
|
|
||||||
|
**键盘输入:**
|
||||||
|
- WASD / 方向键 → 移动向量
|
||||||
|
- 对角线归一化(×0.7071)
|
||||||
|
|
||||||
|
**鼠标输入:**
|
||||||
|
- 位置追踪 (clientX, clientY)
|
||||||
|
- 左右键状态
|
||||||
|
|
||||||
|
**buildInputState:**
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
seq: sequenceNumber++, // 递增序列号
|
||||||
|
dx: movement.dx, // 水平输入 [-1, 1]
|
||||||
|
dy: movement.dy, // 垂直输入 [-1, 1]
|
||||||
|
aimX: mouseGroundPos.x, // 瞄准点 X
|
||||||
|
aimY: mouseGroundPos.y // 瞄准点 Y
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 大厅 UI — lobby.js
|
||||||
|
|
||||||
|
两个视图状态:
|
||||||
|
|
||||||
|
**房间列表视图:**
|
||||||
|
- 玩家名称输入框
|
||||||
|
- 创建房间 / 刷新按钮
|
||||||
|
- 房间列表(点击 Join 加入)
|
||||||
|
|
||||||
|
**房间视图:**
|
||||||
|
- 玩家列表(显示准备状态)
|
||||||
|
- Ready 按钮
|
||||||
|
- Start Game 按钮(仅房主可见)
|
||||||
|
- Leave 按钮
|
||||||
|
|
||||||
|
### 4.7 网格碰撞 — grid.js
|
||||||
|
|
||||||
|
**Grid 类:**
|
||||||
|
- `parseMap(mapData)`: 解析二维数组
|
||||||
|
- `isWall(gx, gy)`: 越界返回 true,检查 cells == 1
|
||||||
|
- `worldToGrid(wx, wy)`: 世界坐标 → 网格坐标
|
||||||
|
- `isWalkable(wx, wy, size)`: 检查四个角是否可通行
|
||||||
|
|
||||||
|
**generateDefaultMap:**
|
||||||
|
- 边界墙(最外层)
|
||||||
|
- 预设墙体段(14段)
|
||||||
|
- 4个角落出生点(周围自动清理墙体)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 网络协议
|
||||||
|
|
||||||
|
### 消息格式
|
||||||
|
|
||||||
|
所有消息使用统一的 `{type, data}` 信封格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "xxx", "data": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 消息类型汇总
|
||||||
|
|
||||||
|
| 方向 | type | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| C→S | `create_room` | 创建房间 `{playerName}` |
|
||||||
|
| C→S | `join_room` | 加入房间 `{roomId, playerName}` |
|
||||||
|
| C→S | `leave_room` | 离开房间 |
|
||||||
|
| C→S | `room_list` | 请求房间列表 |
|
||||||
|
| C→S | `ready` | 切换准备状态 |
|
||||||
|
| C→S | `start_game` | 开始游戏(仅房主) |
|
||||||
|
| C→S | `player_input` | 玩家输入 `{dx, dy, aimX, aimY, seq}` |
|
||||||
|
| S→C | `room_list` | 房间列表 `{rooms[]}` |
|
||||||
|
| S→C | `room_state` | 房间状态 `{roomId, hostId, isHost, playerId, players[]}` |
|
||||||
|
| S→C | `game_started` | 游戏开始 `{playerId, mapData, players[]}` |
|
||||||
|
| S→C | `game_state` | 游戏状态 `{players[{id, x, y, angle, health, lastProcessedSeq}], gameTime}` |
|
||||||
|
| S→C | `error` | 错误 `{message}` |
|
||||||
|
|
||||||
|
### 通信时序
|
||||||
|
|
||||||
|
```
|
||||||
|
Client A Server Client B
|
||||||
|
| | |
|
||||||
|
|── create_room ──────→| |
|
||||||
|
|←── room_state ──────| |
|
||||||
|
| |←── join_room ────────|
|
||||||
|
|←── room_state ──────|─── room_state ──────→|
|
||||||
|
| | |
|
||||||
|
|── ready ───────────→| |
|
||||||
|
|←── room_state ──────|─── room_state ──────→|
|
||||||
|
| | |
|
||||||
|
|── start_game ──────→| |
|
||||||
|
|←── game_started ────|─── game_started ────→|
|
||||||
|
| | |
|
||||||
|
|═══ player_input ═══→| |
|
||||||
|
| |═══ game_state ══════→|
|
||||||
|
|←══ game_state ══════| |
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 数据流
|
||||||
|
|
||||||
|
### 6.1 玩家移动完整数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
InputManager (键盘/鼠标)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
buildInputState() → {seq, dx, dy, aimX, aimY}
|
||||||
|
│
|
||||||
|
├──→ _applyLocalPrediction() [客户端预测]
|
||||||
|
│ ├── 新位置计算
|
||||||
|
│ ├── 碰撞检测
|
||||||
|
│ └── 更新 Three.js 模型
|
||||||
|
│
|
||||||
|
└──→ network.sendInput() ──→ WebSocket → Server
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GameService.processPlayerInput()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ECSWorld.update(dt)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
PlayerInputSystem.update()
|
||||||
|
│
|
||||||
|
├── 更新 Position 组件
|
||||||
|
└── 更新朝向角度
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
StateSyncSystem.buildGameState()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
broadcast → WebSocket → Client
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_processServerState()
|
||||||
|
│
|
||||||
|
├── 远程玩家: 直接更新
|
||||||
|
└── 本地玩家: _reconcileLocalPlayer()
|
||||||
|
├── 应用服务器状态
|
||||||
|
├── 移除已确认输入
|
||||||
|
└── 重放未确认输入
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
[创建房间]
|
||||||
|
Client → create_room → Server → RoomManager.addRoom() → Room 实例
|
||||||
|
|
||||||
|
[加入房间]
|
||||||
|
Client → join_room → Server → RoomManager.joinRoom() → 广播 room_state
|
||||||
|
|
||||||
|
[开始游戏]
|
||||||
|
Client → start_game → Server → GameService.startGame()
|
||||||
|
├── 加载地图
|
||||||
|
├── 创建 ECSWorld + PlayerInputSystem
|
||||||
|
├── 创建 PlayerEntity(分配出生点)
|
||||||
|
├── 启动 GameLoop (30 TPS)
|
||||||
|
└── 广播 game_started(含 mapData + players)
|
||||||
|
|
||||||
|
[游戏进行中]
|
||||||
|
GameLoop.tick() → ECSWorld.update() → PlayerInputSystem → 广播 game_state
|
||||||
|
|
||||||
|
[玩家断开]
|
||||||
|
onClose → RoomManager.leaveRoom() → 清理游戏会话(如房间为空)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 关键设计模式
|
||||||
|
|
||||||
|
### 7.1 ECS (Entity-Component-System)
|
||||||
|
|
||||||
|
- **Entity**: 整数 ID,仅作为标识符
|
||||||
|
- **Component**: 纯数据(Position, Health, PlayerInput 等)
|
||||||
|
- **System**: 处理逻辑(PlayerInputSystem, StateSyncSystem)
|
||||||
|
|
||||||
|
优势:解耦数据与逻辑,便于扩展新实体类型。
|
||||||
|
|
||||||
|
### 7.2 客户端预测 + 服务器校正
|
||||||
|
|
||||||
|
- 客户端立即响应输入,不等待服务器
|
||||||
|
- 服务器每帧广播权威状态
|
||||||
|
- 客户端用序列号匹配已处理输入,重新模拟未确认输入
|
||||||
|
- 结果:零延迟手感 + 服务器权威防作弊
|
||||||
|
|
||||||
|
### 7.3 固定时间步长
|
||||||
|
|
||||||
|
- 客户端和服务器均使用 30 TPS
|
||||||
|
- 渲染帧率与逻辑帧率解耦(渲染用 requestAnimationFrame)
|
||||||
|
- 保证确定性模拟
|
||||||
|
|
||||||
|
### 7.4 线程安全
|
||||||
|
|
||||||
|
- ECSWorld 使用 `synchronized(lock)` 保护
|
||||||
|
- RoomManager 使用 `ConcurrentHashMap`
|
||||||
|
- GameLoop 使用单线程 ScheduledExecutorService
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 文件索引
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `GameServerMain.java` | 服务器入口,启动 WebSocket |
|
||||||
|
| `model/Constants.java` | 游戏常量 + 消息类型 |
|
||||||
|
| `model/Room.java` | 房间实体 |
|
||||||
|
| `model/PlayerInfo.java` | 玩家信息 |
|
||||||
|
| `model/GameMap.java` | 地图加载 + 碰撞检测 |
|
||||||
|
| `model/MapData.java` | 地图序列化 DTO |
|
||||||
|
| `model/Wall.java` | 墙体抽象基类 |
|
||||||
|
| `model/StaticWall.java` | 不可破坏墙体 |
|
||||||
|
| `ecs/ECSWorld.java` | ECS 世界容器 |
|
||||||
|
| `ecs/System.java` | 系统接口 |
|
||||||
|
| `ecs/components/Position.java` | 位置组件 |
|
||||||
|
| `ecs/components/PlayerInput.java` | 输入组件 |
|
||||||
|
| `ecs/components/Health.java` | 生命值组件 |
|
||||||
|
| `ecs/components/Collision.java` | 碰撞组件 |
|
||||||
|
| `ecs/components/RenderInfo.java` | 渲染信息组件 |
|
||||||
|
| `server/GameWebSocketServer.java` | WebSocket 服务器 |
|
||||||
|
| `server/RoomManager.java` | 房间生命周期管理 |
|
||||||
|
| `server/GameService.java` | 游戏会话管理 |
|
||||||
|
| `server/GameLoop.java` | 30TPS 固定帧循环 |
|
||||||
|
| `server/MessageUtils.java` | JSON 安全提取工具 |
|
||||||
|
| `server/MapStorage.java` | 文件地图持久化 |
|
||||||
|
| `systems/PlayerInputSystem.java` | 移动 + 碰撞处理 |
|
||||||
|
| `systems/StateSyncSystem.java` | 状态快照构建 |
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `index.html` | HTML 外壳 |
|
||||||
|
| `src/main.js` | 应用入口,大厅/游戏切换 |
|
||||||
|
| `src/style.css` | 全局样式 |
|
||||||
|
| `src/game/engine.js` | 游戏引擎,预测 + 校正 |
|
||||||
|
| `src/game/scene.js` | Three.js 3D 渲染 |
|
||||||
|
| `src/network/client.js` | WebSocket 客户端 |
|
||||||
|
| `src/ui/lobby.js` | 大厅 UI |
|
||||||
|
| `src/utils/constants.js` | 游戏常量 |
|
||||||
|
| `src/utils/grid.js` | 网格碰撞 + 默认地图 |
|
||||||
|
| `src/utils/input.js` | 键盘/鼠标输入 |
|
||||||
256
docs/migration-plan-zh.md
Normal file
256
docs/migration-plan-zh.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# ZP1 最小化迁移计划
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
将 zp1 重写为 zp2,仅保留:房间管理、地图加载、玩家移动、前端必要显示。技术栈不变(Java + Vite/Three.js),保留 ECS 架构和客户端预测。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
- **保留**:房间系统、地图加载/渲染、玩家移动(WASD)、客户端预测、WebSocket 通信
|
||||||
|
- **移除**:僵尸、武器/子弹、炮塔、火焰区域、掉落物、爆炸/特效、HUD、设置界面、地图编辑器、模板系统、对象池
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段一:后端骨架
|
||||||
|
|
||||||
|
### 1.1 项目结构
|
||||||
|
```
|
||||||
|
zp2/backend/
|
||||||
|
├── pom.xml
|
||||||
|
└── src/main/java/com/zombie/game/
|
||||||
|
├── GameServerMain.java # 入口,仅启动 WebSocket 服务器
|
||||||
|
├── model/
|
||||||
|
│ ├── Constants.java # 精简为:GRID_SIZE, TICK_RATE, PLAYER_SIZE, PLAYER_SPEED, MSG_*
|
||||||
|
│ ├── Room.java # 保持原样
|
||||||
|
│ ├── PlayerInfo.java # 保持原样
|
||||||
|
│ ├── GameMap.java # 精简:移除流向场,仅保留 isWall/isWalkable
|
||||||
|
│ ├── MapData.java # 保持,用于地图反序列化
|
||||||
|
│ └── StaticWall.java # 仅保留静态墙
|
||||||
|
├── server/
|
||||||
|
│ ├── GameWebSocketServer.java # 精简:房间操作 + 玩家输入 + 状态广播
|
||||||
|
│ ├── RoomManager.java # 保持原样
|
||||||
|
│ ├── GameService.java # 精简:仅注册 PlayerInputSystem + StateSyncSystem
|
||||||
|
│ ├── GameLoop.java # 保持原样(30 TPS)
|
||||||
|
│ ├── MessageUtils.java # 保持原样
|
||||||
|
│ └── MapStorage.java # 保留,用于加载地图文件
|
||||||
|
├── ecs/
|
||||||
|
│ ├── ECSWorld.java # 精简:仅管理 players,移除僵尸/子弹/掉落物/炮塔/火焰区域
|
||||||
|
│ ├── System.java # 保持接口
|
||||||
|
│ └── components/
|
||||||
|
│ ├── Position.java # 保持
|
||||||
|
│ ├── PlayerInput.java # 精简:移除手雷相关字段
|
||||||
|
│ ├── Health.java # 精简
|
||||||
|
│ ├── Collision.java # 保持
|
||||||
|
│ └── RenderInfo.java # 精简
|
||||||
|
└── systems/
|
||||||
|
├── PlayerInputSystem.java # 保持:移动 + 碰撞 + 朝向
|
||||||
|
└── StateSyncSystem.java # 精简:仅广播玩家位置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 关键变更
|
||||||
|
|
||||||
|
**Constants.java**
|
||||||
|
- 保留:`GRID_SIZE=32`, `TICK_RATE=30`, `PLAYER_SIZE=0.8f`, `PLAYER_SPEED=5.0f`, `MSG_*` 消息类型常量
|
||||||
|
- 移除:`ZOMBIE_SIZE`, `LOOT_*`, 武器常量
|
||||||
|
|
||||||
|
**GameMap.java**
|
||||||
|
- 保留:`loadFromJson()`, `isWall()`, `isWalkable()`, `getCells()`, `getSpawnPoints()`
|
||||||
|
- 移除:整个 `FlowField`,`updateFlowField()`, `getFlowDirection()`, `addNutWall()`, 坚果墙逻辑
|
||||||
|
- 仅保留 `StaticWall`(移除 `NutWall`, `TurretWall`)
|
||||||
|
|
||||||
|
**ECSWorld.java**
|
||||||
|
- 保留:`entities`, `players`, `playerIdToEntity`, `map`, `systems`, `lock`
|
||||||
|
- 移除:`zombies`, `playerBullets`, `zombieBullets`, `loots`, `fireZones`, `turrets`, `wallEntities`
|
||||||
|
- 移除:所有对象池
|
||||||
|
- 移除:`createZombieEntity()`, `createBulletEntity()`, `createGrenadeEntity()`, `createMolotovEntity()`, `createLootEntity()`, `createFireZoneEntity()`, `createTurretEntity()`
|
||||||
|
- 保留:`createPlayerEntity()`(精简版,无 WeaponState/RespawnState)
|
||||||
|
- 保留:`update(dt)` — 清理临时数据,遍历系统执行
|
||||||
|
|
||||||
|
**GameService.java**
|
||||||
|
- `startGame()`:仅注册 `PlayerInputSystem` 和 `StateSyncSystem`(移除其他10个系统)
|
||||||
|
- 保留:`processPlayerInput()`, `stopGame()`
|
||||||
|
|
||||||
|
**PlayerInputSystem.java**
|
||||||
|
- 保留:移动(dx/dy * speed)、逐轴墙壁碰撞、朝向计算
|
||||||
|
- 移除:武器射击逻辑(如有)
|
||||||
|
|
||||||
|
**StateSyncSystem.java**
|
||||||
|
- 精简广播为:`{players: [{id, x, y, angle, health}], gameTime}`
|
||||||
|
- 移除:zombies, bullets, zombieBullets, loots, explosions, removedBullets, waveNumber, score
|
||||||
|
|
||||||
|
**Room.java / RoomManager.java / GameLoop.java / MessageUtils.java**
|
||||||
|
- 保持原样(或微调清理)
|
||||||
|
|
||||||
|
**GameWebSocketServer.java**
|
||||||
|
- 保留:`onMessage()` 分发、房间 CRUD 处理、`handlePlayerInput()`、`broadcastGameState()`
|
||||||
|
- 可选移除:房间列表广播定时器(或保留用于大厅刷新)
|
||||||
|
|
||||||
|
### 1.3 地图数据
|
||||||
|
- 保留 `maps/d540209a.json` 格式(walls + playerSpawns + zombieSpawns)
|
||||||
|
- 复制一份到 `zp2/maps/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段二:前端骨架
|
||||||
|
|
||||||
|
### 2.1 项目结构
|
||||||
|
```
|
||||||
|
zp2/frontend/
|
||||||
|
├── package.json # 同样依赖(three, vite)
|
||||||
|
├── vite.config.js # 同样代理配置
|
||||||
|
├── index.html # 同样最小化外壳
|
||||||
|
└── src/
|
||||||
|
├── main.js # 精简:大厅 → 游戏切换
|
||||||
|
├── style.css # 仅大厅 + 房间 + 画布样式
|
||||||
|
├── game/
|
||||||
|
│ ├── engine.js # 精简:仅玩家,无僵尸/子弹/战斗
|
||||||
|
│ └── scene.js # 精简:仅地图 + 玩家,无特效
|
||||||
|
├── network/
|
||||||
|
│ └── client.js # 保持原样
|
||||||
|
├── ui/
|
||||||
|
│ └── lobby.js # 保持原样(房间管理 UI)
|
||||||
|
└── utils/
|
||||||
|
├── constants.js # 精简:仅地图 + 玩家 + 网络常量
|
||||||
|
├── grid.js # 精简:仅 Grid 类 + generateDefaultMap
|
||||||
|
└── input.js # 精简:仅移动 + 瞄准
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 关键变更
|
||||||
|
|
||||||
|
**constants.js**
|
||||||
|
- 保留:`GRID_SIZE`, `CELL_SIZE`, `PLAYER_SIZE`, `TICK_RATE`, `TICK_INTERVAL`
|
||||||
|
- 保留:`PLAYER_CONFIG`(MAX_HEALTH, SPEED)
|
||||||
|
- 保留:`MSG_TYPE`(所有房间 + 游戏消息类型)
|
||||||
|
- 移除:`WEAPONS`, `WEAPON_CONFIG`, `ZOMBIE_CONFIG`, `ZOMBIE_SIZE`
|
||||||
|
|
||||||
|
**grid.js**
|
||||||
|
- 保留:`Grid` 类的 `parseMap()`, `isWall()`, `worldToGrid()`, `gridToWorld()`, `isWalkable()`
|
||||||
|
- 保留:`generateDefaultMap()`(备用默认地图)
|
||||||
|
- 移除:`findPath()`, `getSpawnPoints()`, `isSpawnPoint()`
|
||||||
|
|
||||||
|
**input.js**
|
||||||
|
- 保留:`attach()`, `detach()`, `getMovement()`, `buildInputState()`(仅移动 + 瞄准)
|
||||||
|
- 移除:`getSelectedWeapon()`, 武器快捷键绑定
|
||||||
|
- 精简 `buildInputState()` 返回 `{seq, dx, dy, aimX, aimY}`(无 firing, weaponIndex, grenade 字段)
|
||||||
|
|
||||||
|
**engine.js(约666→250行)**
|
||||||
|
- 保留:`connect()`, `start()`, `stop()`, `_loop()`, `_tick()`
|
||||||
|
- 保留:`_applyLocalPrediction()` — 带碰撞的移动预测
|
||||||
|
- 保留:`_reconcileLocalPlayer()` — 服务器校正
|
||||||
|
- 保留:`_initPlayers()`, `_addPlayer()`, `_removePlayer()`
|
||||||
|
- 精简 `_processServerState()`:仅同步玩家位置,移除所有僵尸/子弹/掉落物/炮塔/特效处理
|
||||||
|
- 移除:`_handleGrenadeCharge()`, `_checkBulletHit()`, 武器状态, 手雷状态
|
||||||
|
- 移除:`zombies`, `bullets`, `zombieBullets`, `loots`, `turrets` 映射表
|
||||||
|
|
||||||
|
**scene.js(约1331→250行)**
|
||||||
|
- 保留:构造函数(场景、相机、渲染器、灯光、窗口自适应)
|
||||||
|
- 保留:`buildMap(mapData)` — 地板 + 墙壁
|
||||||
|
- 保留:`createPlayerModel()`, `addPlayer()`, `removePlayer()`, `updatePlayer()`
|
||||||
|
- 保留:`updateCamera()`, `getMouseGroundPos()`, `render()`
|
||||||
|
- 移除:所有僵尸渲染(约180行)
|
||||||
|
- 移除:所有子弹渲染(约230行)
|
||||||
|
- 移除:所有特效(约250行)
|
||||||
|
- 移除:炮塔、掉落物、坚果墙、手雷目标指示器(约215行)
|
||||||
|
- 移除:`updateEffects()`(或简化为空)
|
||||||
|
|
||||||
|
**lobby.js** — 保持原样
|
||||||
|
|
||||||
|
**main.js**
|
||||||
|
- 保留:App 类、大厅绑定、游戏开始切换
|
||||||
|
- 移除:HUD 初始化、设置界面初始化
|
||||||
|
- 移除:`_updateHUD()` 调用
|
||||||
|
|
||||||
|
**style.css**
|
||||||
|
- 保留:大厅样式、房间样式、画布容器样式
|
||||||
|
- 移除:HUD 样式、武器面板、手雷充能、击杀信息、设置弹窗
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 阶段三:清理与验证
|
||||||
|
|
||||||
|
### 3.1 消息协议验证
|
||||||
|
确保以下消息端到端正常工作:
|
||||||
|
- `CREATE_ROOM` / `JOIN_ROOM` / `LEAVE_ROOM` / `READY` / `START_GAME`
|
||||||
|
- `ROOM_LIST` / `ROOM_STATE` / `GAME_STARTED` / `ERROR`
|
||||||
|
- `PLAYER_INPUT`(仅移动:dx, dy, aimX, aimY, seq)
|
||||||
|
- `GAME_STATE`(仅玩家数据)
|
||||||
|
|
||||||
|
### 3.2 测试流程
|
||||||
|
1. 启动后端:`mvn package && java -jar target/*.jar`
|
||||||
|
2. 启动前端:`npm run dev`
|
||||||
|
3. 打开两个浏览器标签页
|
||||||
|
4. 标签页1:创建房间 → 标签页2:加入房间 → 标签页1:开始游戏
|
||||||
|
5. 两个玩家应能在 32x32 地图上互相看到对方移动
|
||||||
|
6. WASD 移动响应流畅(客户端预测)
|
||||||
|
7. 玩家与墙壁碰撞正确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待创建文件清单(共36个)
|
||||||
|
|
||||||
|
### 后端(23个文件)
|
||||||
|
| 序号 | 文件 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `backend/pom.xml` | 从 zp1 复制 |
|
||||||
|
| 2 | `GameServerMain.java` | 精简版 |
|
||||||
|
| 3 | `model/Constants.java` | 精简版 |
|
||||||
|
| 4 | `model/Room.java` | 基本保持 |
|
||||||
|
| 5 | `model/PlayerInfo.java` | 基本保持 |
|
||||||
|
| 6 | `model/GameMap.java` | 精简版(无流向场) |
|
||||||
|
| 7 | `model/MapData.java` | 基本保持 |
|
||||||
|
| 8 | `model/StaticWall.java` | 基本保持 |
|
||||||
|
| 9 | `ecs/System.java` | 保持 |
|
||||||
|
| 10 | `ecs/ECSWorld.java` | 精简版 |
|
||||||
|
| 11 | `ecs/components/Position.java` | 保持 |
|
||||||
|
| 12 | `ecs/components/PlayerInput.java` | 精简版 |
|
||||||
|
| 13 | `ecs/components/Health.java` | 精简版 |
|
||||||
|
| 14 | `ecs/components/Collision.java` | 保持 |
|
||||||
|
| 15 | `ecs/components/RenderInfo.java` | 精简版 |
|
||||||
|
| 16 | `systems/PlayerInputSystem.java` | 基本保持 |
|
||||||
|
| 17 | `systems/StateSyncSystem.java` | 精简版 |
|
||||||
|
| 18 | `server/GameWebSocketServer.java` | 精简版 |
|
||||||
|
| 19 | `server/RoomManager.java` | 基本保持 |
|
||||||
|
| 20 | `server/GameService.java` | 精简版 |
|
||||||
|
| 21 | `server/GameLoop.java` | 基本保持 |
|
||||||
|
| 22 | `server/MessageUtils.java` | 保持 |
|
||||||
|
| 23 | `server/MapStorage.java` | 基本保持 |
|
||||||
|
|
||||||
|
### 前端(12个文件)
|
||||||
|
| 序号 | 文件 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 24 | `frontend/package.json` | 保持 |
|
||||||
|
| 25 | `frontend/vite.config.js` | 保持 |
|
||||||
|
| 26 | `frontend/index.html` | 保持 |
|
||||||
|
| 27 | `frontend/src/main.js` | 精简版 |
|
||||||
|
| 28 | `frontend/src/style.css` | 精简版 |
|
||||||
|
| 29 | `frontend/src/game/engine.js` | 精简版 |
|
||||||
|
| 30 | `frontend/src/game/scene.js` | 精简版 |
|
||||||
|
| 31 | `frontend/src/network/client.js` | 基本保持 |
|
||||||
|
| 32 | `frontend/src/ui/lobby.js` | 保持 |
|
||||||
|
| 33 | `frontend/src/utils/constants.js` | 精简版 |
|
||||||
|
| 34 | `frontend/src/utils/grid.js` | 精简版 |
|
||||||
|
| 35 | `frontend/src/utils/input.js` | 精简版 |
|
||||||
|
|
||||||
|
### 数据文件(1个)
|
||||||
|
| 序号 | 文件 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 36 | `maps/d540209a.json` | 从 zp1 复制 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键设计决策
|
||||||
|
|
||||||
|
| 决策 | 选择 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 技术栈 | Java 17 + Vite + Three.js | 与 zp1 一致,迁移成本最低 |
|
||||||
|
| ECS 架构 | 保留 | 结构清晰,便于后续扩展 |
|
||||||
|
| 客户端预测 | 保留 | 移动手感更好,代码量不大 |
|
||||||
|
| 流向场寻路 | 移除 | 仅僵尸 AI 需要 |
|
||||||
|
| 地图格式 | 保持 JSON | 兼容现有地图文件 |
|
||||||
|
| WebSocket 协议 | 保持相同消息类型 | 前后端保持同步 |
|
||||||
|
| 大厅 UI | 保持原样 | 房间管理是核心功能 |
|
||||||
|
| HUD/设置 | 移除 | 不在本次范围内 |
|
||||||
|
| 地图编辑器 | 移除 | 不在本次范围内 |
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
- **StateSyncSystem**:精简时需小心处理消息格式,确保前端能正确解析
|
||||||
|
- **ECSWorld**:移除组件映射表后,需检查所有引用这些组件的系统
|
||||||
|
- **客户端预测**:engine.js 中的校正逻辑引用了武器状态,需验证精简版是否正常工作
|
||||||
143
docs/migration-plan.md
Normal file
143
docs/migration-plan.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# ZP1 Minimal Migration Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Rewrite zp1 into zp2, keeping only: room management, map loading, player movement, and minimal frontend display. Same tech stack (Java + Vite/Three.js), ECS architecture, client prediction.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- **Keep**: Room system, map loading/rendering, player movement (WASD), client prediction, WebSocket communication
|
||||||
|
- **Strip**: Zombies, weapons/bullets, turrets, fire zones, loots, explosions/effects, HUD, settings, map designer, template system, object pool
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Backend Skeleton
|
||||||
|
|
||||||
|
### 1.1 Project structure
|
||||||
|
```
|
||||||
|
zp2/backend/
|
||||||
|
├── pom.xml
|
||||||
|
└── src/main/java/com/zombie/game/
|
||||||
|
├── GameServerMain.java
|
||||||
|
├── model/
|
||||||
|
│ ├── Constants.java
|
||||||
|
│ ├── Room.java
|
||||||
|
│ ├── PlayerInfo.java
|
||||||
|
│ ├── GameMap.java
|
||||||
|
│ ├── MapData.java
|
||||||
|
│ └── StaticWall.java
|
||||||
|
├── server/
|
||||||
|
│ ├── GameWebSocketServer.java
|
||||||
|
│ ├── RoomManager.java
|
||||||
|
│ ├── GameService.java
|
||||||
|
│ ├── GameLoop.java
|
||||||
|
│ ├── MessageUtils.java
|
||||||
|
│ └── MapStorage.java
|
||||||
|
├── ecs/
|
||||||
|
│ ├── ECSWorld.java
|
||||||
|
│ ├── System.java
|
||||||
|
│ └── components/
|
||||||
|
│ ├── Position.java
|
||||||
|
│ ├── PlayerInput.java
|
||||||
|
│ ├── Health.java
|
||||||
|
│ ├── Collision.java
|
||||||
|
│ └── RenderInfo.java
|
||||||
|
└── systems/
|
||||||
|
├── PlayerInputSystem.java
|
||||||
|
└── StateSyncSystem.java
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Key changes
|
||||||
|
|
||||||
|
**Constants.java** — Keep `GRID_SIZE`, `TICK_RATE`, `PLAYER_SIZE`, `PLAYER_SPEED`, `MSG_*`. Remove zombie/weapon/loot constants.
|
||||||
|
|
||||||
|
**GameMap.java** — Keep `loadFromJson()`, `isWall()`, `isWalkable()`, `getCells()`, `getSpawnPoints()`. Remove `FlowField` entirely.
|
||||||
|
|
||||||
|
**ECSWorld.java** — Keep `entities`, `players`, `playerIdToEntity`, `map`, `systems`, `lock`. Remove zombies/bullets/loots/turrets/fireZones. Remove object pools. Keep `createPlayerEntity()` only.
|
||||||
|
|
||||||
|
**GameService.java** — Register only `PlayerInputSystem` + `StateSyncSystem`.
|
||||||
|
|
||||||
|
**StateSyncSystem.java** — Broadcast `{players: [{id, x, y, angle, health}], gameTime}` only.
|
||||||
|
|
||||||
|
**GameWebSocketServer.java** — Keep room CRUD + player input + state broadcast.
|
||||||
|
|
||||||
|
### 1.3 Map data
|
||||||
|
- Copy `maps/d540209a.json` (walls + playerSpawns + zombieSpawns)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Frontend Skeleton
|
||||||
|
|
||||||
|
### 2.1 Project structure
|
||||||
|
```
|
||||||
|
zp2/frontend/
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js
|
||||||
|
├── index.html
|
||||||
|
└── src/
|
||||||
|
├── main.js
|
||||||
|
├── style.css
|
||||||
|
├── game/
|
||||||
|
│ ├── engine.js
|
||||||
|
│ └── scene.js
|
||||||
|
├── network/
|
||||||
|
│ └── client.js
|
||||||
|
├── ui/
|
||||||
|
│ └── lobby.js
|
||||||
|
└── utils/
|
||||||
|
├── constants.js
|
||||||
|
├── grid.js
|
||||||
|
└── input.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Key changes
|
||||||
|
|
||||||
|
**constants.js** — Keep `GRID_SIZE`, `PLAYER_SIZE`, `TICK_RATE`, `TICK_INTERVAL`, `PLAYER_CONFIG`, `MSG_TYPE`. Remove `WEAPONS`, `WEAPON_CONFIG`, `ZOMBIE_CONFIG`.
|
||||||
|
|
||||||
|
**grid.js** — Keep `Grid` class (`parseMap`, `isWall`, `worldToGrid`, `gridToWorld`, `isWalkable`). Remove `findPath`, `getSpawnPoints`.
|
||||||
|
|
||||||
|
**input.js** — Keep `attach/detach`, `getMovement`, `buildInputState` (movement + aim only). Remove weapon bindings.
|
||||||
|
|
||||||
|
**engine.js (~666→~250 lines)** — Keep connect, loop, tick, local prediction, reconciliation. Strip zombie/bullet/loot/turret processing.
|
||||||
|
|
||||||
|
**scene.js (~1331→~250 lines)** — Keep constructor, lighting, `buildMap`, player rendering, camera, `getMouseGroundPos`. Remove zombie/bullet/effect/turret/loot rendering.
|
||||||
|
|
||||||
|
**lobby.js** — Keep as-is.
|
||||||
|
|
||||||
|
**main.js** — Keep lobby wiring + game transition. Remove HUD/settings.
|
||||||
|
|
||||||
|
**style.css** — Keep lobby + room + canvas styles. Remove HUD/weapon/kill-feed styles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Cleanup & Verification
|
||||||
|
|
||||||
|
### 3.1 Message protocol
|
||||||
|
- `CREATE_ROOM` / `JOIN_ROOM` / `LEAVE_ROOM` / `READY` / `START_GAME`
|
||||||
|
- `ROOM_LIST` / `ROOM_STATE` / `GAME_STARTED` / `ERROR`
|
||||||
|
- `PLAYER_INPUT` (dx, dy, aimX, aimY, seq)
|
||||||
|
- `GAME_STATE` (players only)
|
||||||
|
|
||||||
|
### 3.2 Test flow
|
||||||
|
1. `mvn package && java -jar target/*.jar`
|
||||||
|
2. `npm run dev`
|
||||||
|
3. Open two tabs → Create room → Join room → Start game
|
||||||
|
4. Both players see each other move, WASD responsive, wall collision works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Create (36 total)
|
||||||
|
|
||||||
|
| # | File | Source |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1-23 | Backend Java files (see 1.1) | Simplified from zp1 |
|
||||||
|
| 24-35 | Frontend JS/HTML/CSS (see 2.1) | Simplified from zp1 |
|
||||||
|
| 36 | `maps/d540209a.json` | Copy from zp1 |
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Reason |
|
||||||
|
|----------|--------|--------|
|
||||||
|
| Tech stack | Java 17 + Vite + Three.js | Same as zp1 |
|
||||||
|
| ECS | Keep | Structure preserved |
|
||||||
|
| Client prediction | Keep | Better movement feel |
|
||||||
|
| Flow field | Remove | Zombie-only feature |
|
||||||
|
| HUD/Settings | Remove | Not in scope |
|
||||||
1
frontend/dist/assets/index-BeHdn1hs.css
vendored
Normal file
1
frontend/dist/assets/index-BeHdn1hs.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;color:#e0e0e0;font-family:Segoe UI,system-ui,-apple-system,sans-serif;overflow:hidden}canvas{display:block;width:100vw;height:100vh}.lobby{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem}.lobby h1{font-size:3rem;color:#48f;margin-bottom:2rem;text-shadow:0 0 20px rgba(68,136,255,.3)}.lobby h2{font-size:2rem;color:#48f;margin-bottom:1.5rem}.lobby-form{display:flex;flex-direction:column;gap:1rem;width:100%;max-width:400px}.lobby-form input{padding:.75rem 1rem;background:#16213e;border:1px solid #334466;border-radius:6px;color:#e0e0e0;font-size:1rem;outline:none}.lobby-form input:focus{border-color:#48f}.lobby-actions{display:flex;gap:.5rem}button{padding:.6rem 1.2rem;background:#48f;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.9rem;font-weight:600;transition:background .2s}button:hover{background:#37e}button:active{background:#26d}.room-list{margin-top:1.5rem;width:100%;max-width:500px}.room-list .empty{text-align:center;color:#667;padding:2rem}.room-item{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background:#16213e;border-radius:6px;margin-bottom:.5rem}.room-name{font-weight:600;flex:1}.room-players{color:#889;margin-right:1rem}.join-btn{padding:.4rem .8rem;font-size:.8rem}.room-view{max-width:500px;width:100%}.player-list{width:100%;margin-bottom:1.5rem}.player-item{display:flex;justify-content:space-between;align-items:center;padding:.6rem 1rem;background:#16213e;border-radius:6px;margin-bottom:.4rem}.player-item.ready{border-left:3px solid #44ff88}.ready-status{font-size:.8rem;font-weight:600;color:#667}.player-item.ready .ready-status{color:#4f8}.room-actions{display:flex;gap:.5rem;justify-content:center}#startBtn{background:#4a4}#startBtn:hover{background:#393}#leaveBtn{background:#a44}#leaveBtn:hover{background:#933}
|
||||||
3863
frontend/dist/assets/index-BpzQrr0d.js
vendored
Normal file
3863
frontend/dist/assets/index-BpzQrr0d.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ZP2</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BpzQrr0d.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BeHdn1hs.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ZP2</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1152
frontend/package-lock.json
generated
Normal file
1152
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
frontend/package.json
Normal file
17
frontend/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "zp2-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.170.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
337
frontend/src/game/engine.js
Normal file
337
frontend/src/game/engine.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
// Game engine core logic / 游戏引擎核心逻辑
|
||||||
|
import { TICK_RATE, TICK_INTERVAL, MSG_TYPE, PLAYER_CONFIG } from '../utils/constants.js'
|
||||||
|
import { Grid } from '../utils/grid.js'
|
||||||
|
import { InputManager } from '../utils/input.js'
|
||||||
|
import { GameScene } from './scene.js'
|
||||||
|
import { NetworkClient } from '../network/client.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main game engine that handles game loop, player prediction, and state reconciliation / 主游戏引擎,处理游戏循环、玩家预测和状态同步
|
||||||
|
*/
|
||||||
|
export class GameEngine {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas
|
||||||
|
this.scene = new GameScene(canvas)
|
||||||
|
this.input = new InputManager()
|
||||||
|
this.network = new NetworkClient()
|
||||||
|
this.grid = null
|
||||||
|
this.mapData = null
|
||||||
|
|
||||||
|
// Local player identifier / 本地玩家标识
|
||||||
|
this.localPlayerId = null
|
||||||
|
// Map of all players by ID / 所有玩家的 ID 映射
|
||||||
|
this.players = new Map()
|
||||||
|
// Map of all zombies by ID / 所有僵尸的 ID 映射
|
||||||
|
this.zombies = new Map()
|
||||||
|
|
||||||
|
// Game loop state / 游戏循环状态
|
||||||
|
this.running = false
|
||||||
|
this.lastTick = 0
|
||||||
|
this.accumulator = 0
|
||||||
|
|
||||||
|
// Pending input buffer for reconciliation / 待处理的输入缓冲,用于状态同步
|
||||||
|
this.pendingInputs = []
|
||||||
|
|
||||||
|
// Current game time from server / 来自服务器的当前游戏时间
|
||||||
|
this.gameTime = 0
|
||||||
|
|
||||||
|
// External state update callback / 外部状态更新回调
|
||||||
|
this.onStateUpdate = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to game server / 连接游戏服务器
|
||||||
|
async connect(url) {
|
||||||
|
await this.network.connect(url)
|
||||||
|
this._setupNetworkHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register network event handlers / 注册网络事件处理器
|
||||||
|
_setupNetworkHandlers() {
|
||||||
|
// Handle game start from server / 处理服务器发送的游戏开始信号
|
||||||
|
this.network.on(MSG_TYPE.GAME_STARTED, (data) => {
|
||||||
|
if (!data.mapData) {
|
||||||
|
console.error('Server did not provide map data / 服务器未提供地图数据')
|
||||||
|
alert('Failed to load map data from server. / 无法从服务器加载地图数据。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.localPlayerId = data.playerId
|
||||||
|
this.mapData = data.mapData
|
||||||
|
this.grid = new Grid(this.mapData)
|
||||||
|
this.scene.buildMap(this.mapData)
|
||||||
|
this._initPlayers(data.players)
|
||||||
|
this.start()
|
||||||
|
if (this.onStateUpdate) this.onStateUpdate('game_started', data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle periodic game state updates / 处理定期游戏状态更新
|
||||||
|
this.network.on(MSG_TYPE.GAME_STATE, (data) => {
|
||||||
|
this._processServerState(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle new player joining / 处理新玩家加入
|
||||||
|
this.network.on(MSG_TYPE.PLAYER_JOIN, (data) => {
|
||||||
|
this._addPlayer(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle player leaving / 处理玩家离开
|
||||||
|
this.network.on(MSG_TYPE.PLAYER_LEAVE, (data) => {
|
||||||
|
this._removePlayer(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle server errors / 处理服务器错误
|
||||||
|
this.network.on(MSG_TYPE.ERROR, (data) => {
|
||||||
|
console.error('Server error:', data.message)
|
||||||
|
alert(data.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all players from server data / 根据服务器数据初始化所有玩家
|
||||||
|
_initPlayers(playersData) {
|
||||||
|
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
|
||||||
|
for (const p of playersData) {
|
||||||
|
const isLocal = p.id === this.localPlayerId
|
||||||
|
const color = colors[p.index % colors.length]
|
||||||
|
this.players.set(p.id, {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
angle: 0,
|
||||||
|
health: PLAYER_CONFIG.MAX_HEALTH,
|
||||||
|
color,
|
||||||
|
isLocal
|
||||||
|
})
|
||||||
|
this.scene.addPlayer(p.id, p.x, p.y, color, isLocal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new player to the game / 向游戏中添加新玩家
|
||||||
|
_addPlayer(data) {
|
||||||
|
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
|
||||||
|
const isLocal = data.id === this.localPlayerId
|
||||||
|
const color = colors[data.index % colors.length]
|
||||||
|
this.players.set(data.id, {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
angle: 0,
|
||||||
|
health: PLAYER_CONFIG.MAX_HEALTH,
|
||||||
|
color,
|
||||||
|
isLocal
|
||||||
|
})
|
||||||
|
this.scene.addPlayer(data.id, data.x, data.y, color, isLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a player from the game / 从游戏中移除玩家
|
||||||
|
_removePlayer(data) {
|
||||||
|
this.players.delete(data.id)
|
||||||
|
this.scene.removePlayer(data.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the game loop / 启动游戏循环
|
||||||
|
start() {
|
||||||
|
if (this.running) return
|
||||||
|
this.running = true
|
||||||
|
this.input.attach()
|
||||||
|
this.lastTick = performance.now()
|
||||||
|
this._loop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the game loop / 停止游戏循环
|
||||||
|
stop() {
|
||||||
|
this.running = false
|
||||||
|
this.input.detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main game loop with fixed timestep / 固定时间步长的主游戏循环
|
||||||
|
_loop() {
|
||||||
|
if (!this.running) return
|
||||||
|
requestAnimationFrame(() => this._loop())
|
||||||
|
|
||||||
|
const now = performance.now()
|
||||||
|
const delta = now - this.lastTick
|
||||||
|
this.lastTick = now
|
||||||
|
|
||||||
|
this.accumulator += delta
|
||||||
|
// Run fixed ticks / 执行固定时间步长更新
|
||||||
|
while (this.accumulator >= TICK_INTERVAL) {
|
||||||
|
this._tick()
|
||||||
|
this.accumulator -= TICK_INTERVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update camera to follow local player / 更新相机跟随本地玩家
|
||||||
|
const localPlayer = this.players.get(this.localPlayerId)
|
||||||
|
if (localPlayer) {
|
||||||
|
this.scene.updateCamera(localPlayer.x, localPlayer.y)
|
||||||
|
}
|
||||||
|
this.scene.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single tick update: input, prediction, and network send / 单次tick更新:输入处理、预测和网络发送
|
||||||
|
_tick() {
|
||||||
|
if (!this.localPlayerId) return
|
||||||
|
|
||||||
|
const localPlayer = this.players.get(this.localPlayerId)
|
||||||
|
if (!localPlayer || localPlayer.health <= 0) return
|
||||||
|
|
||||||
|
// Get mouse position on ground plane / 获取鼠标在地面上的位置
|
||||||
|
const mouseGroundPos = this.scene.getMouseGroundPos(this.input.mouse.x, this.input.mouse.y)
|
||||||
|
this.input.mouse.groundX = mouseGroundPos.x
|
||||||
|
this.input.mouse.groundY = mouseGroundPos.y
|
||||||
|
|
||||||
|
const inputState = this.input.buildInputState(mouseGroundPos)
|
||||||
|
|
||||||
|
// Apply client-side prediction / 应用客户端预测
|
||||||
|
this._applyLocalPrediction(inputState)
|
||||||
|
|
||||||
|
// Buffer input for reconciliation / 缓存输入用于状态同步
|
||||||
|
this.pendingInputs.push(inputState)
|
||||||
|
if (this.pendingInputs.length > 60) {
|
||||||
|
this.pendingInputs.splice(0, this.pendingInputs.length - 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.network.sendInput(inputState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply local movement prediction / 应用本地移动预测
|
||||||
|
_applyLocalPrediction(inputState) {
|
||||||
|
const player = this.players.get(this.localPlayerId)
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
const speed = PLAYER_CONFIG.SPEED
|
||||||
|
const dt = TICK_INTERVAL / 1000
|
||||||
|
let newX = player.x + inputState.dx * speed * dt
|
||||||
|
let newY = player.y + inputState.dy * speed * dt
|
||||||
|
|
||||||
|
// Check wall collisions / 检查墙壁碰撞
|
||||||
|
if (this.grid) {
|
||||||
|
if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x
|
||||||
|
if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to map bounds / 限制在地图范围内
|
||||||
|
const maxX = this.grid.width - 0.5
|
||||||
|
const maxY = this.grid.height - 0.5
|
||||||
|
newX = Math.max(0.5, Math.min(maxX, newX))
|
||||||
|
newY = Math.max(0.5, Math.min(maxY, newY))
|
||||||
|
|
||||||
|
player.x = newX
|
||||||
|
player.y = newY
|
||||||
|
|
||||||
|
// Calculate facing angle toward aim point / 计算朝向瞄准点的角度
|
||||||
|
const dx = inputState.aimX - player.x
|
||||||
|
const dy = inputState.aimY - player.y
|
||||||
|
player.angle = Math.atan2(dx, dy)
|
||||||
|
|
||||||
|
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process server state update / 处理服务器状态更新
|
||||||
|
_processServerState(state) {
|
||||||
|
if (state.players) {
|
||||||
|
for (const ps of state.players) {
|
||||||
|
const player = this.players.get(ps.id)
|
||||||
|
if (player) {
|
||||||
|
if (ps.id === this.localPlayerId) {
|
||||||
|
this._reconcileLocalPlayer(ps)
|
||||||
|
} else {
|
||||||
|
player.x = ps.x
|
||||||
|
player.y = ps.y
|
||||||
|
player.angle = ps.angle
|
||||||
|
player.health = ps.health
|
||||||
|
this.scene.updatePlayer(ps.id, ps.x, ps.y, ps.angle, ps.health)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync zombies
|
||||||
|
if (state.zombies) {
|
||||||
|
const serverZombieIds = new Set()
|
||||||
|
for (const zs of state.zombies) {
|
||||||
|
const id = zs.id
|
||||||
|
serverZombieIds.add(id)
|
||||||
|
if (!this.zombies.has(id)) {
|
||||||
|
this.zombies.set(id, { id, x: zs.x, y: zs.y })
|
||||||
|
this.scene.addZombie(id, zs.x, zs.y)
|
||||||
|
} else {
|
||||||
|
const zombie = this.zombies.get(id)
|
||||||
|
zombie.x = zs.x
|
||||||
|
zombie.y = zs.y
|
||||||
|
this.scene.updateZombie(id, zs.x, zs.y, zs.angle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id] of this.zombies) {
|
||||||
|
if (!serverZombieIds.has(id)) {
|
||||||
|
this.zombies.delete(id)
|
||||||
|
this.scene.removeZombie(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync nut walls
|
||||||
|
if (state.nutWalls) {
|
||||||
|
for (const nw of state.nutWalls) {
|
||||||
|
this.scene.updateNutWall(nw.x, nw.y, nw.health, nw.maxHealth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.gameTime !== undefined) this.gameTime = state.gameTime
|
||||||
|
|
||||||
|
if (this.onStateUpdate) this.onStateUpdate('state', state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile local player state with server authority / 用服务器权威数据同步本地玩家状态
|
||||||
|
_reconcileLocalPlayer(serverState) {
|
||||||
|
const player = this.players.get(this.localPlayerId)
|
||||||
|
if (!player) return
|
||||||
|
|
||||||
|
let lastProcessedSeq = serverState.lastProcessedSeq || 0
|
||||||
|
|
||||||
|
// Apply server state / 应用服务器状态
|
||||||
|
player.x = serverState.x
|
||||||
|
player.y = serverState.y
|
||||||
|
player.angle = serverState.angle
|
||||||
|
player.health = serverState.health
|
||||||
|
|
||||||
|
// Remove acknowledged inputs / 移除已确认的输入
|
||||||
|
this.pendingInputs = this.pendingInputs.filter(input => input.seq > lastProcessedSeq)
|
||||||
|
|
||||||
|
// Re-apply unacknowledged inputs / 重新应用未确认的输入
|
||||||
|
if (player.health > 0) {
|
||||||
|
const speed = PLAYER_CONFIG.SPEED
|
||||||
|
const dt = TICK_INTERVAL / 1000
|
||||||
|
for (const input of this.pendingInputs) {
|
||||||
|
let newX = player.x + input.dx * speed * dt
|
||||||
|
let newY = player.y + input.dy * speed * dt
|
||||||
|
|
||||||
|
if (this.grid) {
|
||||||
|
if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x
|
||||||
|
if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxX = this.grid.width - 0.5
|
||||||
|
const maxY = this.grid.height - 0.5
|
||||||
|
newX = Math.max(0.5, Math.min(maxX, newX))
|
||||||
|
newY = Math.max(0.5, Math.min(maxY, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up resources / 清理资源
|
||||||
|
destroy() {
|
||||||
|
this.stop()
|
||||||
|
this.network.disconnect()
|
||||||
|
this.scene.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
302
frontend/src/game/scene.js
Normal file
302
frontend/src/game/scene.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
// 3D scene rendering with Three.js / 使用 Three.js 进行3D场景渲染
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { PLAYER_SIZE } from '../utils/constants.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the 3D scene, camera, lighting, player models, zombie models, and wall meshes
|
||||||
|
*/
|
||||||
|
export class GameScene {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene()
|
||||||
|
this.scene.background = new THREE.Color(0x1a1a2e)
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200)
|
||||||
|
this.cameraOffset = new THREE.Vector3(0, 25, 18)
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
|
||||||
|
this.renderer.setSize(window.innerWidth, window.innerHeight)
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||||
|
this.renderer.shadowMap.enabled = true
|
||||||
|
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||||
|
|
||||||
|
this.players = new Map()
|
||||||
|
this.zombies = new Map()
|
||||||
|
this.wallMeshes = []
|
||||||
|
this.nutWallMeshes = new Map()
|
||||||
|
|
||||||
|
this._setupLighting()
|
||||||
|
this._setupResize()
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupLighting() {
|
||||||
|
const ambient = new THREE.AmbientLight(0x404060, 0.6)
|
||||||
|
this.scene.add(ambient)
|
||||||
|
|
||||||
|
const dirLight = new THREE.DirectionalLight(0xffeedd, 0.8)
|
||||||
|
dirLight.position.set(16, 30, 16)
|
||||||
|
dirLight.castShadow = true
|
||||||
|
dirLight.shadow.mapSize.width = 2048
|
||||||
|
dirLight.shadow.mapSize.height = 2048
|
||||||
|
dirLight.shadow.camera.near = 0.5
|
||||||
|
dirLight.shadow.camera.far = 80
|
||||||
|
dirLight.shadow.camera.left = -20
|
||||||
|
dirLight.shadow.camera.right = 20
|
||||||
|
dirLight.shadow.camera.top = 20
|
||||||
|
dirLight.shadow.camera.bottom = -20
|
||||||
|
this.scene.add(dirLight)
|
||||||
|
|
||||||
|
const pointLight = new THREE.PointLight(0xff4400, 0.3, 50)
|
||||||
|
pointLight.position.set(16, 10, 16)
|
||||||
|
this.scene.add(pointLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupResize() {
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.camera.aspect = window.innerWidth / window.innerHeight
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
this.renderer.setSize(window.innerWidth, window.innerHeight)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build map: floor, static walls (1), player spawns (2), nut walls (4)
|
||||||
|
buildMap(mapData) {
|
||||||
|
for (const mesh of this.wallMeshes) {
|
||||||
|
this.scene.remove(mesh)
|
||||||
|
mesh.geometry.dispose()
|
||||||
|
mesh.material.dispose()
|
||||||
|
}
|
||||||
|
this.wallMeshes = []
|
||||||
|
for (const [, entry] of this.nutWallMeshes) {
|
||||||
|
this.scene.remove(entry.mesh)
|
||||||
|
entry.mesh.geometry.dispose()
|
||||||
|
entry.mesh.material.dispose()
|
||||||
|
}
|
||||||
|
this.nutWallMeshes.clear()
|
||||||
|
|
||||||
|
const mapHeight = mapData.length
|
||||||
|
const mapWidth = mapData[0]?.length || 0
|
||||||
|
|
||||||
|
const floorGeo = new THREE.PlaneGeometry(mapWidth, mapHeight)
|
||||||
|
const floorMat = new THREE.MeshLambertMaterial({ color: 0x2a2a3a })
|
||||||
|
const floor = new THREE.Mesh(floorGeo, floorMat)
|
||||||
|
floor.rotation.x = -Math.PI / 2
|
||||||
|
floor.position.set(mapWidth / 2, 0, mapHeight / 2)
|
||||||
|
floor.receiveShadow = true
|
||||||
|
this.scene.add(floor)
|
||||||
|
this.wallMeshes.push(floor)
|
||||||
|
|
||||||
|
const wallGeo = new THREE.BoxGeometry(1, 1.5, 1)
|
||||||
|
const wallMat = new THREE.MeshLambertMaterial({ color: 0x555577 })
|
||||||
|
const nutWallGeo = new THREE.BoxGeometry(1, 1.2, 1)
|
||||||
|
const nutWallMat = new THREE.MeshLambertMaterial({ color: 0x8B6914 })
|
||||||
|
const spawnGeo = new THREE.BoxGeometry(1, 0.1, 1)
|
||||||
|
const spawnMat = new THREE.MeshLambertMaterial({ color: 0x00ff88, transparent: true, opacity: 0.5 })
|
||||||
|
const zombieSpawnGeo = new THREE.BoxGeometry(1, 0.1, 1)
|
||||||
|
const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4444, transparent: true, opacity: 0.4 })
|
||||||
|
|
||||||
|
for (let y = 0; y < mapHeight; y++) {
|
||||||
|
for (let x = 0; x < mapWidth; x++) {
|
||||||
|
const cell = mapData[y] && mapData[y][x]
|
||||||
|
if (cell === 1) {
|
||||||
|
const wall = new THREE.Mesh(wallGeo, wallMat)
|
||||||
|
wall.position.set(x + 0.5, 0.75, y + 0.5)
|
||||||
|
wall.castShadow = true
|
||||||
|
wall.receiveShadow = true
|
||||||
|
this.scene.add(wall)
|
||||||
|
this.wallMeshes.push(wall)
|
||||||
|
} else if (cell === 2) {
|
||||||
|
const spawn = new THREE.Mesh(spawnGeo, spawnMat)
|
||||||
|
spawn.position.set(x + 0.5, 0.05, y + 0.5)
|
||||||
|
this.scene.add(spawn)
|
||||||
|
this.wallMeshes.push(spawn)
|
||||||
|
} else if (cell === 3) {
|
||||||
|
const zsp = new THREE.Mesh(zombieSpawnGeo, zombieSpawnMat)
|
||||||
|
zsp.position.set(x + 0.5, 0.05, y + 0.5)
|
||||||
|
this.scene.add(zsp)
|
||||||
|
this.wallMeshes.push(zsp)
|
||||||
|
} else if (cell === 4) {
|
||||||
|
const nutWall = new THREE.Mesh(nutWallGeo, nutWallMat.clone())
|
||||||
|
nutWall.position.set(x + 0.5, 0.6, y + 0.5)
|
||||||
|
nutWall.castShadow = true
|
||||||
|
nutWall.receiveShadow = true
|
||||||
|
this.scene.add(nutWall)
|
||||||
|
this.wallMeshes.push(nutWall)
|
||||||
|
const key = x + ',' + y
|
||||||
|
this.nutWallMeshes.set(key, { mesh: nutWall, x, y, maxHealth: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update nut wall appearance based on health
|
||||||
|
updateNutWall(x, y, health, maxHealth) {
|
||||||
|
const key = x + ',' + y
|
||||||
|
const entry = this.nutWallMeshes.get(key)
|
||||||
|
if (!entry) return
|
||||||
|
const ratio = health / maxHealth
|
||||||
|
const r = 0.55 + (1 - ratio) * 0.45
|
||||||
|
const g = 0.41 * ratio
|
||||||
|
const b = 0.08 * ratio
|
||||||
|
entry.mesh.material.color.setRGB(r, g, b)
|
||||||
|
if (health <= 0) {
|
||||||
|
this.scene.remove(entry.mesh)
|
||||||
|
entry.mesh.geometry.dispose()
|
||||||
|
entry.mesh.material.dispose()
|
||||||
|
this.nutWallMeshes.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlayerModel(color = 0x4488ff) {
|
||||||
|
const group = new THREE.Group()
|
||||||
|
|
||||||
|
const bodyGeo = new THREE.CylinderGeometry(PLAYER_SIZE / 2, PLAYER_SIZE / 2, 0.8, 8)
|
||||||
|
const bodyMat = new THREE.MeshLambertMaterial({ color })
|
||||||
|
const body = new THREE.Mesh(bodyGeo, bodyMat)
|
||||||
|
body.position.y = 0.4
|
||||||
|
body.castShadow = true
|
||||||
|
group.add(body)
|
||||||
|
|
||||||
|
const headGeo = new THREE.SphereGeometry(0.2, 8, 8)
|
||||||
|
const headMat = new THREE.MeshLambertMaterial({ color: 0xffcc99 })
|
||||||
|
const head = new THREE.Mesh(headGeo, headMat)
|
||||||
|
head.position.y = 1.0
|
||||||
|
head.castShadow = true
|
||||||
|
group.add(head)
|
||||||
|
|
||||||
|
const gunGeo = new THREE.BoxGeometry(0.08, 0.08, 0.5)
|
||||||
|
const gunMat = new THREE.MeshLambertMaterial({ color: 0x333333 })
|
||||||
|
const gun = new THREE.Mesh(gunGeo, gunMat)
|
||||||
|
gun.position.set(0, 0.5, 0.4)
|
||||||
|
group.add(gun)
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlayer(id, x, y, color, isLocal = false) {
|
||||||
|
const model = this.createPlayerModel(color)
|
||||||
|
model.position.set(x, 0, y)
|
||||||
|
this.scene.add(model)
|
||||||
|
this.players.set(id, { model, isLocal })
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlayer(id) {
|
||||||
|
const player = this.players.get(id)
|
||||||
|
if (player) {
|
||||||
|
this.scene.remove(player.model)
|
||||||
|
this.players.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayer(id, x, y, angle, health) {
|
||||||
|
const player = this.players.get(id)
|
||||||
|
if (player) {
|
||||||
|
player.model.position.x = x
|
||||||
|
player.model.position.z = y
|
||||||
|
player.model.rotation.y = angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zombie rendering ---
|
||||||
|
|
||||||
|
createZombieModel() {
|
||||||
|
const group = new THREE.Group()
|
||||||
|
|
||||||
|
// Body - dark green
|
||||||
|
const bodyGeo = new THREE.CylinderGeometry(0.35, 0.4, 0.9, 8)
|
||||||
|
const bodyMat = new THREE.MeshLambertMaterial({ color: 0x3a5a2a })
|
||||||
|
const body = new THREE.Mesh(bodyGeo, bodyMat)
|
||||||
|
body.position.y = 0.45
|
||||||
|
body.castShadow = true
|
||||||
|
group.add(body)
|
||||||
|
|
||||||
|
// Head - slightly green
|
||||||
|
const headGeo = new THREE.SphereGeometry(0.22, 8, 8)
|
||||||
|
const headMat = new THREE.MeshLambertMaterial({ color: 0x6b8e4e })
|
||||||
|
const head = new THREE.Mesh(headGeo, headMat)
|
||||||
|
head.position.y = 1.1
|
||||||
|
head.castShadow = true
|
||||||
|
group.add(head)
|
||||||
|
|
||||||
|
// Arms - thin cylinders pointing forward
|
||||||
|
const armGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.6, 6)
|
||||||
|
const armMat = new THREE.MeshLambertMaterial({ color: 0x3a5a2a })
|
||||||
|
const leftArm = new THREE.Mesh(armGeo, armMat)
|
||||||
|
leftArm.position.set(-0.3, 0.7, 0.3)
|
||||||
|
leftArm.rotation.x = -Math.PI / 3
|
||||||
|
group.add(leftArm)
|
||||||
|
const rightArm = new THREE.Mesh(armGeo, armMat)
|
||||||
|
rightArm.position.set(0.3, 0.7, 0.3)
|
||||||
|
rightArm.rotation.x = -Math.PI / 3
|
||||||
|
group.add(rightArm)
|
||||||
|
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
addZombie(id, x, y) {
|
||||||
|
const model = this.createZombieModel()
|
||||||
|
model.position.set(x, 0, y)
|
||||||
|
this.scene.add(model)
|
||||||
|
this.zombies.set(id, { model })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeZombie(id) {
|
||||||
|
const zombie = this.zombies.get(id)
|
||||||
|
if (zombie) {
|
||||||
|
this.scene.remove(zombie.model)
|
||||||
|
this.zombies.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateZombie(id, x, y, angle) {
|
||||||
|
const zombie = this.zombies.get(id)
|
||||||
|
if (zombie) {
|
||||||
|
zombie.model.position.x = x
|
||||||
|
zombie.model.position.z = y
|
||||||
|
zombie.model.rotation.y = angle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera follows target
|
||||||
|
updateCamera(targetX, targetY) {
|
||||||
|
const target = new THREE.Vector3(targetX, 0, targetY)
|
||||||
|
const desiredPos = target.clone().add(this.cameraOffset)
|
||||||
|
this.camera.position.lerp(desiredPos, 0.1)
|
||||||
|
this.camera.lookAt(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMouseGroundPos(mouseX, mouseY) {
|
||||||
|
const mouse = new THREE.Vector2(
|
||||||
|
(mouseX / window.innerWidth) * 2 - 1,
|
||||||
|
-(mouseY / window.innerHeight) * 2 + 1
|
||||||
|
)
|
||||||
|
const raycaster = new THREE.Raycaster()
|
||||||
|
raycaster.setFromCamera(mouse, this.camera)
|
||||||
|
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
|
||||||
|
const intersection = new THREE.Vector3()
|
||||||
|
raycaster.ray.intersectPlane(groundPlane, intersection)
|
||||||
|
if (intersection) {
|
||||||
|
return { x: intersection.x, y: intersection.z }
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.renderer.dispose()
|
||||||
|
this.scene.traverse(child => {
|
||||||
|
if (child.geometry) child.geometry.dispose()
|
||||||
|
if (child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach(m => m.dispose())
|
||||||
|
} else {
|
||||||
|
child.material.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
141
frontend/src/main.js
Normal file
141
frontend/src/main.js
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Application entry point / 应用入口
|
||||||
|
import { MSG_TYPE } from './utils/constants.js'
|
||||||
|
import { GameEngine } from './game/engine.js'
|
||||||
|
import { LobbyUI } from './ui/lobby.js'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
// WebSocket server URL / WebSocket 服务器地址
|
||||||
|
const WS_URL = `ws://${window.location.hostname}:8080/ws`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application class that manages lobby and game transitions / 主应用类,管理大厅和游戏之间的切换
|
||||||
|
*/
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
// Root DOM element / 根 DOM 元素
|
||||||
|
this.appEl = document.getElementById('app')
|
||||||
|
|
||||||
|
// Lobby container element / 大厅容器元素
|
||||||
|
this.lobbyEl = document.createElement('div')
|
||||||
|
// Game canvas element / 游戏画布元素
|
||||||
|
this.gameCanvasEl = document.createElement('canvas')
|
||||||
|
|
||||||
|
this.appEl.appendChild(this.lobbyEl)
|
||||||
|
this.appEl.appendChild(this.gameCanvasEl)
|
||||||
|
|
||||||
|
// Hide game canvas initially / 初始时隐藏游戏画布
|
||||||
|
this.gameCanvasEl.style.display = 'none'
|
||||||
|
|
||||||
|
// Game engine instance / 游戏引擎实例
|
||||||
|
this.engine = null
|
||||||
|
// Lobby UI instance / 大厅 UI 实例
|
||||||
|
this.lobby = new LobbyUI(this.lobbyEl)
|
||||||
|
|
||||||
|
// Player session info / 玩家会话信息
|
||||||
|
this.playerId = null
|
||||||
|
this.roomId = null
|
||||||
|
this.isHost = false
|
||||||
|
this.playerName = ''
|
||||||
|
|
||||||
|
this._setupLobby()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up lobby event callbacks / 设置大厅事件回调
|
||||||
|
_setupLobby() {
|
||||||
|
this.lobby.onCreateRoom = (name) => {
|
||||||
|
this.playerName = name
|
||||||
|
this._ensureConnection().then(() => {
|
||||||
|
this.engine.network.createRoom(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lobby.onJoinRoom = (roomId, name) => {
|
||||||
|
this.playerName = name
|
||||||
|
this._ensureConnection().then(() => {
|
||||||
|
this.engine.network.joinRoom(roomId, name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lobby.onRefreshRooms = () => {
|
||||||
|
this._ensureConnection().then(() => {
|
||||||
|
this.engine.network.requestRoomList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lobby.onReady = () => {
|
||||||
|
if (this.engine) this.engine.network.ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lobby.onStartGame = () => {
|
||||||
|
if (this.engine) this.engine.network.startGame()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lobby.onLeaveRoom = () => {
|
||||||
|
if (this.engine) {
|
||||||
|
this.engine.network.leaveRoom()
|
||||||
|
this.roomId = null
|
||||||
|
this.isHost = false
|
||||||
|
this.lobby.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure WebSocket connection is established / 确保 WebSocket 连接已建立
|
||||||
|
async _ensureConnection() {
|
||||||
|
if (!this.engine) {
|
||||||
|
this.engine = new GameEngine(this.gameCanvasEl)
|
||||||
|
try {
|
||||||
|
await this.engine.connect(WS_URL)
|
||||||
|
this._setupNetworkHandlers()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to connect:', e)
|
||||||
|
alert('Failed to connect to server. Make sure the server is running on port 8080.')
|
||||||
|
}
|
||||||
|
} else if (!this.engine.network.connected) {
|
||||||
|
try {
|
||||||
|
await this.engine.connect(WS_URL)
|
||||||
|
this._setupNetworkHandlers()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to reconnect:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register network message handlers / 注册网络消息处理器
|
||||||
|
_setupNetworkHandlers() {
|
||||||
|
const net = this.engine.network
|
||||||
|
|
||||||
|
// Handle room list updates / 处理房间列表更新
|
||||||
|
net.on(MSG_TYPE.ROOM_LIST, (data) => {
|
||||||
|
this.lobby.updateRoomList(data.rooms)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle room state updates / 处理房间状态更新
|
||||||
|
net.on(MSG_TYPE.ROOM_STATE, (data) => {
|
||||||
|
if (data.playerId) {
|
||||||
|
this.playerId = data.playerId
|
||||||
|
} else if (!this.playerId) {
|
||||||
|
console.warn('ROOM_STATE missing playerId / ROOM_STATE 缺少 playerId')
|
||||||
|
}
|
||||||
|
this.roomId = data.roomId
|
||||||
|
this.isHost = data.isHost || false
|
||||||
|
this.lobby.showRoom(data, this.isHost, this.playerId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle game start event / 处理游戏开始事件
|
||||||
|
net.on(MSG_TYPE.GAME_STARTED, (data) => {
|
||||||
|
this.playerId = data.playerId
|
||||||
|
this.lobbyEl.style.display = 'none'
|
||||||
|
this.gameCanvasEl.style.display = 'block'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle error messages / 处理错误消息
|
||||||
|
net.on(MSG_TYPE.ERROR, (data) => {
|
||||||
|
console.error('Error:', data.message)
|
||||||
|
alert(data.message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the application / 初始化应用
|
||||||
|
const app = new App()
|
||||||
107
frontend/src/network/client.js
Normal file
107
frontend/src/network/client.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// Network client for WebSocket communication / WebSocket 通信网络客户端
|
||||||
|
import { MSG_TYPE } from '../utils/constants.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket client that handles connection and message routing / WebSocket 客户端,处理连接和消息路由
|
||||||
|
*/
|
||||||
|
export class NetworkClient {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null
|
||||||
|
this.connected = false
|
||||||
|
// Message handlers by type / 按类型存储的消息处理器
|
||||||
|
this.handlers = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to WebSocket server / 连接 WebSocket 服务器
|
||||||
|
async connect(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.ws = new WebSocket(url)
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.connected = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = (e) => {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.connected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data)
|
||||||
|
const handlers = this.handlers.get(msg.type)
|
||||||
|
if (handlers) {
|
||||||
|
for (const h of handlers) {
|
||||||
|
h(msg.data || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse message:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from server / 断开服务器连接
|
||||||
|
disconnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
this.ws = null
|
||||||
|
this.connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a message handler for a specific type / 为特定类型注册消息处理器
|
||||||
|
on(type, handler) {
|
||||||
|
if (!this.handlers.has(type)) {
|
||||||
|
this.handlers.set(type, [])
|
||||||
|
}
|
||||||
|
this.handlers.get(type).push(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message to the server / 向服务器发送消息
|
||||||
|
send(type, data = {}) {
|
||||||
|
if (this.ws && this.connected) {
|
||||||
|
this.ws.send(JSON.stringify({ type, data }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new room / 创建新房间
|
||||||
|
createRoom(playerName) {
|
||||||
|
this.send(MSG_TYPE.CREATE_ROOM, { playerName })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join an existing room / 加入已有房间
|
||||||
|
joinRoom(roomId, playerName) {
|
||||||
|
this.send(MSG_TYPE.JOIN_ROOM, { roomId, playerName })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave current room / 离开当前房间
|
||||||
|
leaveRoom() {
|
||||||
|
this.send(MSG_TYPE.LEAVE_ROOM)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request room list / 请求房间列表
|
||||||
|
requestRoomList() {
|
||||||
|
this.send(MSG_TYPE.ROOM_LIST)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal ready state / 发送准备状态
|
||||||
|
ready() {
|
||||||
|
this.send(MSG_TYPE.READY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the game (host only) / 开始游戏(仅房主)
|
||||||
|
startGame() {
|
||||||
|
this.send(MSG_TYPE.START_GAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send player input to server / 向服务器发送玩家输入
|
||||||
|
sendInput(inputState) {
|
||||||
|
this.send(MSG_TYPE.PLAYER_INPUT, inputState)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
frontend/src/style.css
Normal file
180
frontend/src/style.css
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #4488ff;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-shadow: 0 0 20px rgba(68, 136, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #4488ff;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-form input {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #334466;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-form input:focus {
|
||||||
|
border-color: #4488ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lobby-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
background: #4488ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #3377ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
background: #2266dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-list .empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #667;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-name {
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-players {
|
||||||
|
color: #889;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-view {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-list {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item.ready {
|
||||||
|
border-left: 3px solid #44ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item.ready .ready-status {
|
||||||
|
color: #44ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#startBtn {
|
||||||
|
background: #44aa44;
|
||||||
|
}
|
||||||
|
|
||||||
|
#startBtn:hover {
|
||||||
|
background: #339933;
|
||||||
|
}
|
||||||
|
|
||||||
|
#leaveBtn {
|
||||||
|
background: #aa4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#leaveBtn:hover {
|
||||||
|
background: #993333;
|
||||||
|
}
|
||||||
114
frontend/src/ui/lobby.js
Normal file
114
frontend/src/ui/lobby.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Lobby UI for room management / 房间管理大厅 UI
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders and manages the lobby interface for room creation and joining / 渲染和管理用于创建和加入房间的大厅界面
|
||||||
|
*/
|
||||||
|
export class LobbyUI {
|
||||||
|
constructor(container) {
|
||||||
|
this.container = container
|
||||||
|
// Event callbacks / 事件回调
|
||||||
|
this.onCreateRoom = null
|
||||||
|
this.onJoinRoom = null
|
||||||
|
this.onRefreshRooms = null
|
||||||
|
this.onReady = null
|
||||||
|
this.onStartGame = null
|
||||||
|
this.onLeaveRoom = null
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the initial lobby view / 渲染初始大厅视图
|
||||||
|
render() {
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="lobby">
|
||||||
|
<h1>ZP2</h1>
|
||||||
|
<div class="lobby-form">
|
||||||
|
<input type="text" id="playerName" placeholder="Your name" maxlength="16" />
|
||||||
|
<div class="lobby-actions">
|
||||||
|
<button id="createRoom">Create Room</button>
|
||||||
|
<button id="refreshRooms">Refresh Rooms</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="roomList" class="room-list"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Bind create room button / 绑定创建房间按钮
|
||||||
|
this.container.querySelector('#createRoom').onclick = () => {
|
||||||
|
const name = this.container.querySelector('#playerName').value.trim() || 'Player'
|
||||||
|
if (this.onCreateRoom) this.onCreateRoom(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind refresh rooms button / 绑定刷新房间按钮
|
||||||
|
this.container.querySelector('#refreshRooms').onclick = () => {
|
||||||
|
if (this.onRefreshRooms) this.onRefreshRooms()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.style.display = 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the room list display / 更新房间列表显示
|
||||||
|
updateRoomList(rooms) {
|
||||||
|
const listEl = this.container.querySelector('#roomList')
|
||||||
|
if (!rooms || rooms.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="empty">No rooms available</p>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = rooms.map(r => `
|
||||||
|
<div class="room-item" data-id="${r.id}">
|
||||||
|
<span class="room-name">${r.hostName || r.id}'s Room</span>
|
||||||
|
<span class="room-players">${r.playerCount}/4</span>
|
||||||
|
<button class="join-btn" data-id="${r.id}">Join</button>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
// Bind join buttons / 绑定加入按钮
|
||||||
|
listEl.querySelectorAll('.join-btn').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
const name = this.container.querySelector('#playerName').value.trim() || 'Player'
|
||||||
|
if (this.onJoinRoom) this.onJoinRoom(btn.dataset.id, name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the room view with player list / 显示带有玩家列表的房间视图
|
||||||
|
showRoom(data, isHost, playerId) {
|
||||||
|
const players = data.players || []
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="lobby room-view">
|
||||||
|
<h2>${data.roomName || data.roomId}</h2>
|
||||||
|
<div class="player-list">
|
||||||
|
${players.map(p => `
|
||||||
|
<div class="player-item ${p.ready ? 'ready' : ''}">
|
||||||
|
<span>${p.name}${p.id === playerId ? ' (you)' : ''}</span>
|
||||||
|
<span class="ready-status">${p.ready ? 'READY' : 'NOT READY'}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="room-actions">
|
||||||
|
<button id="readyBtn">Ready</button>
|
||||||
|
${isHost ? '<button id="startBtn">Start Game</button>' : ''}
|
||||||
|
<button id="leaveBtn">Leave</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Bind ready button / 绑定准备按钮
|
||||||
|
this.container.querySelector('#readyBtn').onclick = () => {
|
||||||
|
if (this.onReady) this.onReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind start game button (host only) / 绑定开始游戏按钮(仅房主)
|
||||||
|
const startBtn = this.container.querySelector('#startBtn')
|
||||||
|
if (startBtn) {
|
||||||
|
startBtn.onclick = () => {
|
||||||
|
if (this.onStartGame) this.onStartGame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind leave button / 绑定离开按钮
|
||||||
|
this.container.querySelector('#leaveBtn').onclick = () => {
|
||||||
|
if (this.onLeaveRoom) this.onLeaveRoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/utils/constants.js
Normal file
40
frontend/src/utils/constants.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Game configuration constants / 游戏配置常量
|
||||||
|
|
||||||
|
// Size of each cell in world units / 每个格子的世界单位大小
|
||||||
|
export const CELL_SIZE = 1
|
||||||
|
// Player model size / 玩家模型大小
|
||||||
|
export const PLAYER_SIZE = 0.8
|
||||||
|
|
||||||
|
// Network tick rate / 网络tick速率
|
||||||
|
export const TICK_RATE = 30
|
||||||
|
// Time between ticks in ms / 每次tick之间的时间(毫秒)
|
||||||
|
export const TICK_INTERVAL = 1000 / TICK_RATE
|
||||||
|
|
||||||
|
// Player configuration / 玩家配置
|
||||||
|
export const PLAYER_CONFIG = {
|
||||||
|
MAX_HEALTH: 100, // Maximum player health / 玩家最大生命值
|
||||||
|
SPEED: 5 // Player movement speed / 玩家移动速度
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zombie configuration / 僵尸配置
|
||||||
|
export const ZOMBIE_CONFIG = {
|
||||||
|
SIZE: 0.8, // Zombie collision size / 僵尸碰撞体积
|
||||||
|
SPEED: 2 // Zombie movement speed / 僵尸移动速度
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message types for network protocol / 网络协议的消息类型
|
||||||
|
export const MSG_TYPE = {
|
||||||
|
CREATE_ROOM: 'create_room',
|
||||||
|
JOIN_ROOM: 'join_room',
|
||||||
|
LEAVE_ROOM: 'leave_room',
|
||||||
|
ROOM_LIST: 'room_list',
|
||||||
|
ROOM_STATE: 'room_state',
|
||||||
|
READY: 'ready',
|
||||||
|
START_GAME: 'start_game',
|
||||||
|
GAME_STARTED: 'game_started',
|
||||||
|
PLAYER_INPUT: 'player_input',
|
||||||
|
GAME_STATE: 'game_state',
|
||||||
|
PLAYER_JOIN: 'player_join',
|
||||||
|
PLAYER_LEAVE: 'player_leave',
|
||||||
|
ERROR: 'error'
|
||||||
|
}
|
||||||
68
frontend/src/utils/grid.js
Normal file
68
frontend/src/utils/grid.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Grid system for collision detection / 用于碰撞检测的网格系统
|
||||||
|
import { CELL_SIZE } from './constants.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid class for map parsing and collision queries / 网格类,用于地图解析和碰撞查询
|
||||||
|
*/
|
||||||
|
export class Grid {
|
||||||
|
constructor(mapData) {
|
||||||
|
this.cells = []
|
||||||
|
this.width = 0
|
||||||
|
this.height = 0
|
||||||
|
this.parseMap(mapData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse map data into internal grid / 将地图数据解析为内部网格
|
||||||
|
parseMap(mapData) {
|
||||||
|
this.height = mapData.length
|
||||||
|
this.width = mapData[0]?.length || 0
|
||||||
|
this.cells = []
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
this.cells[y] = []
|
||||||
|
for (let x = 0; x < this.width; x++) {
|
||||||
|
this.cells[y][x] = mapData[y][x] || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a grid cell is a wall (static=1 or nut=4) / 检查网格单元格是否为墙
|
||||||
|
isWall(gridX, gridY) {
|
||||||
|
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return true
|
||||||
|
const cell = this.cells[gridY][gridX]
|
||||||
|
return cell === 1 || cell === 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert world coordinates to grid coordinates / 将世界坐标转换为网格坐标
|
||||||
|
worldToGrid(wx, wy) {
|
||||||
|
return {
|
||||||
|
x: Math.floor(wx / CELL_SIZE),
|
||||||
|
y: Math.floor(wy / CELL_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert grid coordinates to world coordinates / 将网格坐标转换为世界坐标
|
||||||
|
gridToWorld(gx, gy) {
|
||||||
|
return {
|
||||||
|
x: gx * CELL_SIZE + CELL_SIZE / 2,
|
||||||
|
y: gy * CELL_SIZE + CELL_SIZE / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a world position is walkable (no wall collisions) / 检查世界位置是否可行走(无墙壁碰撞)
|
||||||
|
isWalkable(wx, wy, size) {
|
||||||
|
const half = size / 2
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
for (const corner of corners) {
|
||||||
|
const g = this.worldToGrid(corner.x, corner.y)
|
||||||
|
if (this.isWall(g.x, g.y)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
94
frontend/src/utils/input.js
Normal file
94
frontend/src/utils/input.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Input manager for keyboard and mouse / 键盘和鼠标输入管理器
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks keyboard and mouse state for player input / 追踪键盘和鼠标状态用于玩家输入
|
||||||
|
*/
|
||||||
|
export class InputManager {
|
||||||
|
constructor() {
|
||||||
|
// Currently pressed keys / 当前按下的按键
|
||||||
|
this.keys = {}
|
||||||
|
// Mouse state / 鼠标状态
|
||||||
|
this.mouse = { x: 0, y: 0, left: false, right: false, groundX: 0, groundY: 0 }
|
||||||
|
|
||||||
|
// Key bindings for movement / 移动按键绑定
|
||||||
|
this.keyBindings = {
|
||||||
|
moveUp: 'KeyW',
|
||||||
|
moveDown: 'KeyS',
|
||||||
|
moveLeft: 'KeyA',
|
||||||
|
moveRight: 'KeyD'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input sequence number for reconciliation / 用于同步的输入序列号
|
||||||
|
this.sequenceNumber = 0
|
||||||
|
|
||||||
|
// Event handler bound references (for attach/detach) / 事件处理器绑定引用(用于挂载/卸载)
|
||||||
|
this._onKeyDown = (e) => {
|
||||||
|
this.keys[e.code] = true
|
||||||
|
}
|
||||||
|
this._onKeyUp = (e) => {
|
||||||
|
this.keys[e.code] = false
|
||||||
|
}
|
||||||
|
this._onMouseMove = (e) => {
|
||||||
|
this.mouse.x = e.clientX
|
||||||
|
this.mouse.y = e.clientY
|
||||||
|
}
|
||||||
|
this._onMouseDown = (e) => {
|
||||||
|
if (e.button === 0) this.mouse.left = true
|
||||||
|
if (e.button === 2) this.mouse.right = true
|
||||||
|
}
|
||||||
|
this._onMouseUp = (e) => {
|
||||||
|
if (e.button === 0) this.mouse.left = false
|
||||||
|
if (e.button === 2) this.mouse.right = false
|
||||||
|
}
|
||||||
|
// Prevent context menu on right click / 阻止右键菜单
|
||||||
|
this._onContextMenu = (e) => e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners / 挂载事件监听器
|
||||||
|
attach() {
|
||||||
|
window.addEventListener('keydown', this._onKeyDown)
|
||||||
|
window.addEventListener('keyup', this._onKeyUp)
|
||||||
|
window.addEventListener('mousemove', this._onMouseMove)
|
||||||
|
window.addEventListener('mousedown', this._onMouseDown)
|
||||||
|
window.addEventListener('mouseup', this._onMouseUp)
|
||||||
|
window.addEventListener('contextmenu', this._onContextMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners / 移除事件监听器
|
||||||
|
detach() {
|
||||||
|
window.removeEventListener('keydown', this._onKeyDown)
|
||||||
|
window.removeEventListener('keyup', this._onKeyUp)
|
||||||
|
window.removeEventListener('mousemove', this._onMouseMove)
|
||||||
|
window.removeEventListener('mousedown', this._onMouseDown)
|
||||||
|
window.removeEventListener('mouseup', this._onMouseUp)
|
||||||
|
window.removeEventListener('contextmenu', this._onContextMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get normalized movement direction / 获取归一化的移动方向
|
||||||
|
getMovement() {
|
||||||
|
let dx = 0, dy = 0
|
||||||
|
if (this.keys[this.keyBindings.moveUp] || this.keys['ArrowUp']) dy -= 1
|
||||||
|
if (this.keys[this.keyBindings.moveDown] || this.keys['ArrowDown']) dy += 1
|
||||||
|
if (this.keys[this.keyBindings.moveLeft] || this.keys['ArrowLeft']) dx -= 1
|
||||||
|
if (this.keys[this.keyBindings.moveRight] || this.keys['ArrowRight']) dx += 1
|
||||||
|
// Normalize diagonal movement / 对角线移动归一化
|
||||||
|
if (dx !== 0 && dy !== 0) {
|
||||||
|
dx *= 0.7071
|
||||||
|
dy *= 0.7071
|
||||||
|
}
|
||||||
|
return { dx, dy }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build complete input state for sending to server / 构建完整的输入状态用于发送给服务器
|
||||||
|
buildInputState(mouseGroundPos) {
|
||||||
|
const movement = this.getMovement()
|
||||||
|
this.sequenceNumber++
|
||||||
|
return {
|
||||||
|
seq: this.sequenceNumber,
|
||||||
|
dx: movement.dx,
|
||||||
|
dy: movement.dy,
|
||||||
|
aimX: mouseGroundPos.x,
|
||||||
|
aimY: mouseGroundPos.y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
464
maps/d540209a.json
Normal file
464
maps/d540209a.json
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
{
|
||||||
|
"id": "d540209a",
|
||||||
|
"name": "my-map",
|
||||||
|
"width": 32,
|
||||||
|
"height": 32,
|
||||||
|
"walls": [
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 0.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 1.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 2.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 3.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 4.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 5.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 6.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 7.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 23.0,
|
||||||
|
"y": 7.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 8.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 23.0,
|
||||||
|
"y": 8.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 8.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 9.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 9.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 10.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 10.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 10.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 11.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 11.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 11.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 12.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 12.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 13.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 13.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 13.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 14.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 14.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 14.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 15.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 15.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 16.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 0.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 1.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 2.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 3.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 6.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 7.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 10.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 11.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 12.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 13.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 14.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 15.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 16.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 17.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 18.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 19.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 20.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 21.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 22.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 23.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 17.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 19.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 20.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 21.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 22.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 28.0,
|
||||||
|
"y": 23.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 29.0,
|
||||||
|
"y": 23.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 28.0,
|
||||||
|
"y": 24.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 9.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 10.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 11.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 12.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 13.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 14.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 15.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 28.0,
|
||||||
|
"y": 25.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 15.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 17.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 18.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 20.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 22.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 25.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 26.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 27.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 28.0,
|
||||||
|
"y": 26.0,
|
||||||
|
"type": "static"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 10.0,
|
||||||
|
"y": 10.0,
|
||||||
|
"type": "nut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 11.0,
|
||||||
|
"y": 10.0,
|
||||||
|
"type": "nut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 16.0,
|
||||||
|
"y": 12.0,
|
||||||
|
"type": "nut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 17.0,
|
||||||
|
"y": 12.0,
|
||||||
|
"type": "nut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 8.0,
|
||||||
|
"y": 20.0,
|
||||||
|
"type": "nut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 9.0,
|
||||||
|
"y": 20.0,
|
||||||
|
"type": "nut"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"playerSpawns": [
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 9,
|
||||||
|
"y": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 14,
|
||||||
|
"y": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 24,
|
||||||
|
"y": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"zombieSpawns": [
|
||||||
|
{
|
||||||
|
"x": 23,
|
||||||
|
"y": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 6,
|
||||||
|
"y": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 15,
|
||||||
|
"y": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": 10,
|
||||||
|
"y": 28
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
81
script/start.bat
Normal file
81
script/start.bat
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
@echo off
|
||||||
|
REM ZP2 Startup Script for Windows / ZP2 Windows 启动脚本
|
||||||
|
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "PROJECT_DIR=%SCRIPT_DIR%.."
|
||||||
|
set "BACKEND_DIR=%PROJECT_DIR%\backend"
|
||||||
|
set "FRONTEND_DIR=%PROJECT_DIR%\frontend"
|
||||||
|
|
||||||
|
echo ================================
|
||||||
|
echo ZP2 Game Server Launcher
|
||||||
|
echo ZP2 游戏服务器启动器
|
||||||
|
echo ================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check prerequisites / 检查依赖
|
||||||
|
echo [INFO] Checking prerequisites / 检查依赖...
|
||||||
|
|
||||||
|
where java >nul 2>nul
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Java not found. Please install Java 17+. / 未找到 Java,请安装 Java 17+。
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for /f "tokens=3" %%g in ('java -version 2^>^1 ^| findstr /i "version"') do (
|
||||||
|
set "JAVA_VER=%%g"
|
||||||
|
set "JAVA_VER=!JAVA_VER:"=!"
|
||||||
|
)
|
||||||
|
echo [INFO] Java !JAVA_VER! found / 找到 Java !JAVA_VER!
|
||||||
|
|
||||||
|
where mvn >nul 2>nul
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Maven not found. Please install Maven. / 未找到 Maven,请安装 Maven。
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [INFO] Maven found / 找到 Maven
|
||||||
|
|
||||||
|
where node >nul 2>nul
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Node.js not found. Please install Node.js. / 未找到 Node.js,请安装 Node.js。
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
for /f "tokens=*" %%v in ('node -v') do echo [INFO] %%v found / 找到 %%v
|
||||||
|
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Build backend / 构建后端
|
||||||
|
echo [INFO] Building backend / 构建后端...
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
call mvn package -q
|
||||||
|
if %ERRORLEVEL% neq 0 (
|
||||||
|
echo [ERROR] Backend build failed / 后端构建失败
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [INFO] Backend built successfully / 后端构建成功
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Start backend / 启动后端
|
||||||
|
echo [INFO] Starting backend server on port 8080 / 启动后端服务器 (端口 8080)...
|
||||||
|
cd /d "%PROJECT_DIR%"
|
||||||
|
start "ZP2 Backend" java -jar backend\target\zombie-crisis-server-1.0.0.jar
|
||||||
|
echo [INFO] Backend started in new window / 后端已在新窗口中启动
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Start frontend / 启动前端
|
||||||
|
echo [INFO] Starting frontend dev server / 启动前端开发服务器...
|
||||||
|
cd /d "%FRONTEND_DIR%"
|
||||||
|
|
||||||
|
if not exist "node_modules\" (
|
||||||
|
echo [INFO] Installing frontend dependencies / 安装前端依赖...
|
||||||
|
call npm install
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [INFO] Frontend dev server starting at http://localhost:3000 / 前端开发服务器地址: http://localhost:3000
|
||||||
|
echo.
|
||||||
|
echo Press Ctrl+C to stop the frontend. Backend runs in a separate window.
|
||||||
|
echo 按 Ctrl+C 停止前端,后端运行在独立窗口中。
|
||||||
|
echo.
|
||||||
|
|
||||||
|
call npm run dev
|
||||||
105
script/start.sh
Executable file
105
script/start.sh
Executable file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ZP2 Startup Script for macOS/Linux / ZP2 macOS/Linux 启动脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BACKEND_DIR="$PROJECT_DIR/backend"
|
||||||
|
FRONTEND_DIR="$PROJECT_DIR/frontend"
|
||||||
|
|
||||||
|
# Colors / 颜色
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||||
|
|
||||||
|
# Check prerequisites / 检查依赖
|
||||||
|
check_prereqs() {
|
||||||
|
info "Checking prerequisites / 检查依赖..."
|
||||||
|
|
||||||
|
if ! command -v java &>/dev/null; then
|
||||||
|
error "Java not found. Please install Java 17+. / 未找到 Java,请安装 Java 17+。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
JAVA_VER=$(java -version 2>&1 | head -1 | cut -d'"' -f2 | cut -d'.' -f1)
|
||||||
|
if [ "$JAVA_VER" -lt 17 ]; then
|
||||||
|
error "Java 17+ required, found Java $JAVA_VER / 需要 Java 17+,当前为 Java $JAVA_VER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Java $JAVA_VER OK"
|
||||||
|
|
||||||
|
if ! command -v mvn &>/dev/null; then
|
||||||
|
error "Maven not found. Please install Maven. / 未找到 Maven,请安装 Maven。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Maven OK"
|
||||||
|
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
error "Node.js not found. Please install Node.js. / 未找到 Node.js,请安装 Node.js。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Node.js $(node -v) OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build backend / 构建后端
|
||||||
|
build_backend() {
|
||||||
|
info "Building backend / 构建后端..."
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
mvn package -q
|
||||||
|
info "Backend built successfully / 后端构建成功"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start backend / 启动后端
|
||||||
|
start_backend() {
|
||||||
|
info "Starting backend server on port 8080 / 启动后端服务器 (端口 8080)..."
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
nohup java -jar backend/target/zombie-crisis-server-1.0.0.jar > "$PROJECT_DIR/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
echo "$BACKEND_PID" > "$PROJECT_DIR/.backend.pid"
|
||||||
|
info "Backend started (PID: $BACKEND_PID) / 后端已启动 (PID: $BACKEND_PID)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start frontend / 启动前端
|
||||||
|
start_frontend() {
|
||||||
|
info "Starting frontend dev server / 启动前端开发服务器..."
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
info "Installing frontend dependencies / 安装前端依赖..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Frontend dev server starting at http://localhost:3000 / 前端开发服务器地址: http://localhost:3000"
|
||||||
|
npm run dev
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle Ctrl+C / 处理 Ctrl+C
|
||||||
|
cleanup() {
|
||||||
|
if [ -f "$PROJECT_DIR/.backend.pid" ]; then
|
||||||
|
PID=$(cat "$PROJECT_DIR/.backend.pid")
|
||||||
|
info "Stopping backend (PID: $PID) / 停止后端 (PID: $PID)..."
|
||||||
|
kill "$PID" 2>/dev/null || true
|
||||||
|
rm -f "$PROJECT_DIR/.backend.pid"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup INT TERM
|
||||||
|
|
||||||
|
# Main / 主流程
|
||||||
|
echo "=================================="
|
||||||
|
echo " ZP2 Game Server Launcher"
|
||||||
|
echo " ZP2 游戏服务器启动器"
|
||||||
|
echo "=================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_prereqs
|
||||||
|
build_backend
|
||||||
|
start_backend
|
||||||
|
start_frontend
|
||||||
Reference in New Issue
Block a user