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

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