Initial commit

This commit is contained in:
wfz
2026-07-26 14:41:36 +08:00
commit f741a0c8e8
90 changed files with 10774 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
*.log
.backend.pid
.DS_Store
.env

View 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
View 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>

View 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);
}
}));
}
}

View 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);
}
}

View 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);
}

View File

@@ -0,0 +1,5 @@
package com.zombie.game.ecs.components;
/** Tag component: entity blocks movement on its grid cell / 标记组件:实体在其网格格子上阻挡移动 */
public class BlocksMovement {
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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";
}

View 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;
}
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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();
}
}
}
}

View 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;
}
}

View File

@@ -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);
}
}

View 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;
}
}
}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()));
}
}
}

View File

@@ -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
}
}

View File

@@ -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]};
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
artifactId=zombie-crisis-server
groupId=com.zombie
version=1.0.0

View File

@@ -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

View File

@@ -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

Binary file not shown.

Binary file not shown.

597
docs/architecture.md Normal file
View 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)
```
- 每 33ms1000/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
View 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
View 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 |

View 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

File diff suppressed because one or more lines are too long

13
frontend/dist/index.html vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View 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
View 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
View 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
View 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()

View 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
View 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
View 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()
}
}
}

View 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'
}

View 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
}
}

View 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
View 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
View 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
View 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
View 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