Initial commit
This commit is contained in:
34
backend/src/main/java/com/zombie/game/GameServerMain.java
Normal file
34
backend/src/main/java/com/zombie/game/GameServerMain.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.zombie.game;
|
||||
|
||||
import com.zombie.game.server.GameWebSocketServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class GameServerMain {
|
||||
private static final Logger logger = LoggerFactory.getLogger(GameServerMain.class);
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
int port = 8080;
|
||||
if (args.length > 0) {
|
||||
try {
|
||||
port = Integer.parseInt(args[0]);
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("Invalid port number, using default: 8080");
|
||||
}
|
||||
}
|
||||
|
||||
GameWebSocketServer server = new GameWebSocketServer(port);
|
||||
server.start();
|
||||
|
||||
logger.info("ZP2 Server started on port {}", port);
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
logger.info("Shutting down server...");
|
||||
try {
|
||||
server.stop();
|
||||
} catch (Exception e) {
|
||||
logger.error("Error during shutdown", e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
133
backend/src/main/java/com/zombie/game/ecs/ECSWorld.java
Normal file
133
backend/src/main/java/com/zombie/game/ecs/ECSWorld.java
Normal file
@@ -0,0 +1,133 @@
|
||||
package com.zombie.game.ecs;
|
||||
|
||||
import com.zombie.game.ecs.components.*;
|
||||
import com.zombie.game.model.Constants;
|
||||
import com.zombie.game.model.GameMap;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** ECS world that manages all entities, components, and systems / ECS世界,管理所有实体、组件和系统 */
|
||||
@Getter
|
||||
public class ECSWorld {
|
||||
private final Object lock = new Object();
|
||||
private final GameMap map;
|
||||
private int nextEntityId;
|
||||
private final Set<Integer> entities = new LinkedHashSet<>();
|
||||
private final Set<Integer> players = new LinkedHashSet<>();
|
||||
private final Set<Integer> zombies = new LinkedHashSet<>();
|
||||
private final Map<Integer, Position> positions = new HashMap<>();
|
||||
private final Map<Integer, Health> healths = new HashMap<>();
|
||||
private final Map<Integer, Collision> collisions = new HashMap<>();
|
||||
private final Map<Integer, RenderInfo> renderInfos = new HashMap<>();
|
||||
private final Map<Integer, PlayerInput> playerInputs = new HashMap<>();
|
||||
private final Map<Integer, BlocksMovement> blocksMovements = new HashMap<>();
|
||||
private final Map<Integer, MovementCost> movementCosts = new HashMap<>();
|
||||
private final Map<Integer, ZombieAI> zombieAIs = new HashMap<>();
|
||||
private final List<System> systems = new ArrayList<>();
|
||||
private float gameTime;
|
||||
private final Map<String, Integer> playerIdToEntity = new HashMap<>();
|
||||
|
||||
public ECSWorld(String mapFilePath) {
|
||||
this.map = new GameMap(mapFilePath);
|
||||
this.nextEntityId = 0;
|
||||
this.gameTime = 0;
|
||||
|
||||
// Wire GameMap to ECS component maps
|
||||
map.setComponentMaps(blocksMovements, movementCosts);
|
||||
|
||||
// Create wall entities from map data
|
||||
createWallsFromMapData();
|
||||
}
|
||||
|
||||
/** Create static wall and nut wall entities from map JSON data */
|
||||
private void createWallsFromMapData() {
|
||||
for (int[] pos : map.getStaticWallData()) {
|
||||
createStaticWallEntity(pos[0], pos[1]);
|
||||
}
|
||||
for (int[] pos : map.getNutWallData()) {
|
||||
createNutWallEntity(pos[0], pos[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a static wall entity (indestructible, blocks movement) */
|
||||
public int createStaticWallEntity(int gx, int gy) {
|
||||
int entityId = createEntity();
|
||||
positions.put(entityId, new Position(gx + 0.5f, gy + 0.5f));
|
||||
blocksMovements.put(entityId, new BlocksMovement());
|
||||
map.registerWall(gx, gy, entityId);
|
||||
return entityId;
|
||||
}
|
||||
|
||||
/** Create a nut wall entity (destructible, blocks movement with cost) */
|
||||
public int createNutWallEntity(int gx, int gy) {
|
||||
int entityId = createEntity();
|
||||
positions.put(entityId, new Position(gx + 0.5f, gy + 0.5f));
|
||||
healths.put(entityId, new Health(Constants.NUT_WALL_MAX_HEALTH));
|
||||
blocksMovements.put(entityId, new BlocksMovement());
|
||||
movementCosts.put(entityId, new MovementCost(Constants.NUT_WALL_MOVEMENT_COST));
|
||||
map.registerWall(gx, gy, entityId);
|
||||
return entityId;
|
||||
}
|
||||
|
||||
/** Create a zombie entity for testing pathfinding */
|
||||
public int createZombieEntity(float x, float y) {
|
||||
int entityId = createEntity();
|
||||
positions.put(entityId, new Position(x, y));
|
||||
healths.put(entityId, new Health(100));
|
||||
collisions.put(entityId, new Collision(Constants.ZOMBIE_SIZE));
|
||||
renderInfos.put(entityId, new RenderInfo(RenderInfo.EntityType.ZOMBIE));
|
||||
zombieAIs.put(entityId, new ZombieAI());
|
||||
zombies.add(entityId);
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public int createEntity() {
|
||||
int id = nextEntityId++;
|
||||
entities.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
public void destroyEntity(int id) {
|
||||
entities.remove(id);
|
||||
players.remove(id);
|
||||
zombies.remove(id);
|
||||
positions.remove(id);
|
||||
healths.remove(id);
|
||||
collisions.remove(id);
|
||||
renderInfos.remove(id);
|
||||
playerInputs.remove(id);
|
||||
blocksMovements.remove(id);
|
||||
movementCosts.remove(id);
|
||||
zombieAIs.remove(id);
|
||||
}
|
||||
|
||||
public int createPlayerEntity(String playerId, String name, float x, float y) {
|
||||
int entityId = createEntity();
|
||||
positions.put(entityId, new Position(x, y));
|
||||
healths.put(entityId, new Health(100));
|
||||
collisions.put(entityId, new Collision(Constants.PLAYER_SIZE));
|
||||
renderInfos.put(entityId, new RenderInfo(RenderInfo.EntityType.PLAYER));
|
||||
playerInputs.put(entityId, new PlayerInput());
|
||||
players.add(entityId);
|
||||
playerIdToEntity.put(playerId, entityId);
|
||||
return entityId;
|
||||
}
|
||||
|
||||
public Integer getPlayerEntity(String playerId) {
|
||||
return playerIdToEntity.get(playerId);
|
||||
}
|
||||
|
||||
public int[][] getMapData() { return map.getCells(); }
|
||||
|
||||
public void update(float dt) {
|
||||
gameTime += dt;
|
||||
for (System system : systems) {
|
||||
system.update(dt, this);
|
||||
}
|
||||
}
|
||||
|
||||
public void addSystem(System system) {
|
||||
systems.add(system);
|
||||
}
|
||||
}
|
||||
7
backend/src/main/java/com/zombie/game/ecs/System.java
Normal file
7
backend/src/main/java/com/zombie/game/ecs/System.java
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.zombie.game.ecs;
|
||||
|
||||
/** Interface for ECS systems that process entities each frame / ECS系统接口,每帧处理实体 */
|
||||
public interface System {
|
||||
// Updates the system with delta time and world reference / 使用增量时间和世界引用更新系统
|
||||
void update(float dt, ECSWorld world);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
/** Tag component: entity blocks movement on its grid cell / 标记组件:实体在其网格格子上阻挡移动 */
|
||||
public class BlocksMovement {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Component storing collision size for entity hitboxes / 存储实体碰撞体大小的组件 */
|
||||
@Data
|
||||
public class Collision {
|
||||
// Collision radius/size for hitbox detection / 用于碰撞检测的碰撞体半径/大小
|
||||
private float size;
|
||||
|
||||
// Constructor setting the collision size / 构造函数,设置碰撞体大小
|
||||
public Collision(float size) {
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Component storing entity health points / 存储实体生命值的组件 */
|
||||
@Data
|
||||
public class Health {
|
||||
// Current health value / 当前生命值
|
||||
private float health;
|
||||
// Maximum health value / 最大生命值
|
||||
private float maxHealth;
|
||||
|
||||
// Constructor setting initial and max health / 构造函数,设置初始和最大生命值
|
||||
public Health(float maxHealth) {
|
||||
this.health = maxHealth;
|
||||
this.maxHealth = maxHealth;
|
||||
}
|
||||
|
||||
// Checks if the entity is still alive / 检查实体是否仍然存活
|
||||
public boolean isAlive() {
|
||||
return health > 0;
|
||||
}
|
||||
|
||||
// Resets health to maximum value / 将生命值重置为最大值
|
||||
public void reset() {
|
||||
this.health = maxHealth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Component storing extra movement cost for flow field pathfinding / 存储流场寻路额外移动代价的组件 */
|
||||
@Data
|
||||
public class MovementCost {
|
||||
/** Extra cost added on top of base (1.0 for straight, 1.414 for diagonal) / 基础代价之外的额外代价 */
|
||||
private float cost;
|
||||
|
||||
public MovementCost(float cost) {
|
||||
this.cost = cost;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Component storing player input state for movement and aiming / 存储玩家移动和瞄准输入状态的组件 */
|
||||
@Data
|
||||
public class PlayerInput {
|
||||
// Movement direction X component / 移动方向X分量
|
||||
private float dx;
|
||||
// Movement direction Y component / 移动方向Y分量
|
||||
private float dy;
|
||||
// Aim target X coordinate / 瞄准目标X坐标
|
||||
private float aimX;
|
||||
// Aim target Y coordinate / 瞄准目标Y坐标
|
||||
private float aimY;
|
||||
// Input sequence number for ordering / 用于排序的输入序列号
|
||||
private int seq;
|
||||
|
||||
// Default constructor initializing all values to zero / 默认构造函数,将所有值初始化为零
|
||||
public PlayerInput() {
|
||||
this.dx = 0;
|
||||
this.dy = 0;
|
||||
this.aimX = 0;
|
||||
this.aimY = 0;
|
||||
this.seq = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Component storing entity position and facing angle / 存储实体位置和朝向角度的组件 */
|
||||
@Data
|
||||
public class Position {
|
||||
// X coordinate / X坐标
|
||||
private float x;
|
||||
// Y coordinate / Y坐标
|
||||
private float y;
|
||||
// Facing angle in radians / 朝向角度(弧度)
|
||||
private float angle;
|
||||
|
||||
// Default constructor initializing to origin / 默认构造函数,初始化为原点
|
||||
public Position() {
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.angle = 0;
|
||||
}
|
||||
|
||||
// Constructor with position only / 仅带位置的构造函数
|
||||
public Position(float x, float y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.angle = 0;
|
||||
}
|
||||
|
||||
// Constructor with position and angle / 带位置和角度的构造函数
|
||||
public Position(float x, float y, float angle) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.angle = angle;
|
||||
}
|
||||
|
||||
// Calculates Euclidean distance to a point / 计算到某点的欧几里得距离
|
||||
public float distanceTo(float px, float py) {
|
||||
float dx = px - x;
|
||||
float dy = py - y;
|
||||
return (float) Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Component storing rendering information for an entity / 存储实体渲染信息的组件 */
|
||||
@Data
|
||||
public class RenderInfo {
|
||||
// Enum defining possible entity types / 定义可能实体类型的枚举
|
||||
public enum EntityType {
|
||||
PLAYER,
|
||||
ZOMBIE
|
||||
}
|
||||
|
||||
// The type of this entity / 此实体的类型
|
||||
private EntityType type;
|
||||
|
||||
// Constructor setting the entity type / 构造函数,设置实体类型
|
||||
public RenderInfo(EntityType type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.zombie.game.ecs.components;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Zombie AI component storing pathfinding target and state / 僵尸AI组件,存储寻路目标和状态 */
|
||||
@Data
|
||||
public class ZombieAI {
|
||||
/** Current movement target X / 当前移动目标X */
|
||||
private float targetX;
|
||||
/** Current movement target Y / 当前移动目标Y */
|
||||
private float targetY;
|
||||
/** Whether zombie has an active target / 是否有活跃目标 */
|
||||
private boolean hasTarget;
|
||||
/** Reserved grid X to prevent overlap / 预留网格X防止重叠 */
|
||||
private int reservedGridX = -1;
|
||||
/** Reserved grid Y to prevent overlap / 预留网格Y防止重叠 */
|
||||
private int reservedGridY = -1;
|
||||
/** Whether zombie holds a grid reservation / 是否持有网格预留 */
|
||||
private boolean reservation;
|
||||
|
||||
public ZombieAI() {
|
||||
this.targetX = 0;
|
||||
this.targetY = 0;
|
||||
this.hasTarget = false;
|
||||
}
|
||||
}
|
||||
56
backend/src/main/java/com/zombie/game/model/Constants.java
Normal file
56
backend/src/main/java/com/zombie/game/model/Constants.java
Normal file
@@ -0,0 +1,56 @@
|
||||
package com.zombie.game.model;
|
||||
|
||||
/** Game constants / 游戏常量 */
|
||||
public class Constants {
|
||||
// Grid cell size in pixels / 网格单元格大小(像素)
|
||||
public static final int GRID_SIZE = 32;
|
||||
// Tick rate per second / 每秒逻辑帧率
|
||||
public static final int TICK_RATE = 30;
|
||||
// Duration of one tick in seconds / 每帧持续时间(秒)
|
||||
public static final float TICK_INTERVAL = 1.0f / TICK_RATE;
|
||||
// Player collision size / 玩家碰撞体积
|
||||
public static final float PLAYER_SIZE = 0.8f;
|
||||
// Player movement speed / 玩家移动速度
|
||||
public static final float PLAYER_SPEED = 5.0f;
|
||||
// Zombie collision size / 僵尸碰撞体积
|
||||
public static final float ZOMBIE_SIZE = 0.8f;
|
||||
// Zombie movement speed / 僵尸移动速度
|
||||
public static final float ZOMBIE_SPEED = 2.0f;
|
||||
// Nut wall max health / 坚果墙最大生命值
|
||||
public static final float NUT_WALL_MAX_HEALTH = 500.0f;
|
||||
// Nut wall extra movement cost for flow field / 坚果墙流场额外移动代价
|
||||
public static final float NUT_WALL_MOVEMENT_COST = 20.0f;
|
||||
// Grid center threshold for retarget / 到达网格中心后重新选方向的阈值
|
||||
public static final float GRID_CENTER_THRESHOLD = 0.15f;
|
||||
// Zombie spawn interval in seconds / 僵尸刷新间隔(秒)
|
||||
public static final float ZOMBIE_SPAWN_INTERVAL = 3.0f;
|
||||
// Max zombie count / 最大僵尸数量
|
||||
public static final int ZOMBIE_MAX_COUNT = 20;
|
||||
|
||||
// Create room message type / 创建房间消息类型
|
||||
public static final String MSG_CREATE_ROOM = "create_room";
|
||||
// Join room message type / 加入房间消息类型
|
||||
public static final String MSG_JOIN_ROOM = "join_room";
|
||||
// Leave room message type / 离开房间消息类型
|
||||
public static final String MSG_LEAVE_ROOM = "leave_room";
|
||||
// Room list message type / 房间列表消息类型
|
||||
public static final String MSG_ROOM_LIST = "room_list";
|
||||
// Room state message type / 房间状态消息类型
|
||||
public static final String MSG_ROOM_STATE = "room_state";
|
||||
// Ready state message type / 准备状态消息类型
|
||||
public static final String MSG_READY = "ready";
|
||||
// Start game message type / 开始游戏消息类型
|
||||
public static final String MSG_START_GAME = "start_game";
|
||||
// Game started notification / 游戏已开始通知
|
||||
public static final String MSG_GAME_STARTED = "game_started";
|
||||
// Player input message type / 玩家输入消息类型
|
||||
public static final String MSG_PLAYER_INPUT = "player_input";
|
||||
// Game state broadcast / 游戏状态广播
|
||||
public static final String MSG_GAME_STATE = "game_state";
|
||||
// Player joined notification / 玩家加入通知
|
||||
public static final String MSG_PLAYER_JOIN = "player_join";
|
||||
// Player left notification / 玩家离开通知
|
||||
public static final String MSG_PLAYER_LEAVE = "player_leave";
|
||||
// Error message type / 错误消息类型
|
||||
public static final String MSG_ERROR = "error";
|
||||
}
|
||||
194
backend/src/main/java/com/zombie/game/model/FlowField.java
Normal file
194
backend/src/main/java/com/zombie/game/model/FlowField.java
Normal file
@@ -0,0 +1,194 @@
|
||||
package com.zombie.game.model;
|
||||
|
||||
import com.zombie.game.ecs.components.BlocksMovement;
|
||||
import com.zombie.game.ecs.components.MovementCost;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/** Flow field navigation using Dijkstra with ECS component-based wall queries / 基于ECS组件查询的Dijkstra流场导航 */
|
||||
public class FlowField {
|
||||
private final int width;
|
||||
private final int height;
|
||||
private float[][] distanceField;
|
||||
private float[][] flowFieldX;
|
||||
private float[][] flowFieldY;
|
||||
private boolean valid;
|
||||
|
||||
public FlowField(int width, int height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.distanceField = new float[height][width];
|
||||
this.flowFieldX = new float[height][width];
|
||||
this.flowFieldY = new float[height][width];
|
||||
this.valid = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update flow field based on player positions and ECS wall components.
|
||||
* @param wallGrid spatial index "x,y" -> entityId
|
||||
* @param blocksMovements blocks-movement component map
|
||||
* @param movementCosts movement-cost component map
|
||||
* @param playerPositions list of [x, y] player positions
|
||||
*/
|
||||
public void update(Map<String, Integer> wallGrid,
|
||||
Map<Integer, BlocksMovement> blocksMovements,
|
||||
Map<Integer, MovementCost> movementCosts,
|
||||
List<float[]> playerPositions) {
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
distanceField[y][x] = Float.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
PriorityQueue<Node> queue = new PriorityQueue<>(Comparator.comparingDouble(n -> n.dist));
|
||||
|
||||
for (float[] pos : playerPositions) {
|
||||
int px = (int) Math.floor(pos[0]);
|
||||
int py = (int) Math.floor(pos[1]);
|
||||
if (px >= 0 && px < width && py >= 0 && py < height) {
|
||||
if (!isBlocked(wallGrid, blocksMovements, px, py)) {
|
||||
distanceField[py][px] = 0;
|
||||
queue.add(new Node(px, py, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int[][] dirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}};
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
Node current = queue.poll();
|
||||
int cx = current.x;
|
||||
int cy = current.y;
|
||||
float currentDist = current.dist;
|
||||
|
||||
if (currentDist > distanceField[cy][cx]) continue;
|
||||
|
||||
for (int[] dir : dirs) {
|
||||
int nx = cx + dir[0];
|
||||
int ny = cy + dir[1];
|
||||
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
||||
|
||||
// Diagonal: check corner blocking
|
||||
if (dir[0] != 0 && dir[1] != 0) {
|
||||
if (isBlocked(wallGrid, blocksMovements, cx + dir[0], cy) ||
|
||||
isBlocked(wallGrid, blocksMovements, cx, cy + dir[1])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
float moveCost = getMovementCost(wallGrid, blocksMovements, movementCosts, nx, ny, dir);
|
||||
if (moveCost == Float.MAX_VALUE) continue;
|
||||
|
||||
float newDist = currentDist + moveCost;
|
||||
|
||||
if (newDist < distanceField[ny][nx]) {
|
||||
distanceField[ny][nx] = newDist;
|
||||
queue.add(new Node(nx, ny, newDist));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute flow directions
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
if (isBlocked(wallGrid, blocksMovements, x, y) || distanceField[y][x] == Float.MAX_VALUE) {
|
||||
flowFieldX[y][x] = 0;
|
||||
flowFieldY[y][x] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
float bestDist = distanceField[y][x];
|
||||
float bestDirX = 0;
|
||||
float bestDirY = 0;
|
||||
|
||||
for (int[] dir : dirs) {
|
||||
int nx = x + dir[0];
|
||||
int ny = y + dir[1];
|
||||
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
||||
if (isBlocked(wallGrid, blocksMovements, nx, ny)) continue;
|
||||
|
||||
if (dir[0] != 0 && dir[1] != 0) {
|
||||
if (isBlocked(wallGrid, blocksMovements, x + dir[0], y) ||
|
||||
isBlocked(wallGrid, blocksMovements, x, y + dir[1])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (distanceField[ny][nx] < bestDist) {
|
||||
bestDist = distanceField[ny][nx];
|
||||
float len = (float) Math.sqrt(dir[0] * dir[0] + dir[1] * dir[1]);
|
||||
bestDirX = dir[0] / len;
|
||||
bestDirY = dir[1] / len;
|
||||
}
|
||||
}
|
||||
|
||||
flowFieldX[y][x] = bestDirX;
|
||||
flowFieldY[y][x] = bestDirY;
|
||||
}
|
||||
}
|
||||
|
||||
valid = true;
|
||||
}
|
||||
|
||||
/** Get flow direction at world position / 获取世界坐标处的流场方向 */
|
||||
public float[] getDirection(float wx, float wy) {
|
||||
int gx = (int) Math.floor(wx);
|
||||
int gy = (int) Math.floor(wy);
|
||||
if (gx < 0 || gx >= width || gy < 0 || gy >= height) {
|
||||
return new float[]{0, 0};
|
||||
}
|
||||
return new float[]{flowFieldX[gy][gx], flowFieldY[gy][gx]};
|
||||
}
|
||||
|
||||
/** Get distance to nearest player at world position / 获取世界坐标处到最近玩家的距离 */
|
||||
public float getDistance(float wx, float wy) {
|
||||
int gx = (int) Math.floor(wx);
|
||||
int gy = (int) Math.floor(wy);
|
||||
if (gx < 0 || gx >= width || gy < 0 || gy >= height) {
|
||||
return Float.MAX_VALUE;
|
||||
}
|
||||
return distanceField[gy][gx];
|
||||
}
|
||||
|
||||
public boolean isValid() { return valid; }
|
||||
public void invalidate() { valid = false; }
|
||||
|
||||
private boolean isBlocked(Map<String, Integer> wallGrid,
|
||||
Map<Integer, BlocksMovement> blocksMovements,
|
||||
int gx, int gy) {
|
||||
if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true;
|
||||
Integer entityId = wallGrid.get(gx + "," + gy);
|
||||
if (entityId == null) return false;
|
||||
return blocksMovements.containsKey(entityId);
|
||||
}
|
||||
|
||||
private float getMovementCost(Map<String, Integer> wallGrid,
|
||||
Map<Integer, BlocksMovement> blocksMovements,
|
||||
Map<Integer, MovementCost> movementCosts,
|
||||
int gx, int gy, int[] dir) {
|
||||
float baseCost = (dir[0] != 0 && dir[1] != 0) ? 1.414f : 1.0f;
|
||||
Integer entityId = wallGrid.get(gx + "," + gy);
|
||||
if (entityId == null) return baseCost;
|
||||
// Completely blocked (no MovementCost but has BlocksMovement)
|
||||
if (blocksMovements.containsKey(entityId) && !movementCosts.containsKey(entityId)) {
|
||||
return Float.MAX_VALUE;
|
||||
}
|
||||
MovementCost mc = movementCosts.get(entityId);
|
||||
if (mc != null) {
|
||||
return baseCost + mc.getCost();
|
||||
}
|
||||
return baseCost;
|
||||
}
|
||||
|
||||
private static class Node {
|
||||
int x, y;
|
||||
float dist;
|
||||
Node(int x, int y, float dist) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.dist = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
197
backend/src/main/java/com/zombie/game/model/GameMap.java
Normal file
197
backend/src/main/java/com/zombie/game/model/GameMap.java
Normal file
@@ -0,0 +1,197 @@
|
||||
package com.zombie.game.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zombie.game.ecs.components.BlocksMovement;
|
||||
import com.zombie.game.ecs.components.MovementCost;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.*;
|
||||
|
||||
/** Game map: spatial index for wall entities, flow field, and spawn points / 游戏地图:墙体实体空间索引、流场和出生点 */
|
||||
@Getter
|
||||
public class GameMap {
|
||||
private final int[][] cells;
|
||||
private final int width;
|
||||
private final int height;
|
||||
/** Spatial index: "x,y" -> wall entity ID / 空间索引:"x,y" -> 墙体实体ID */
|
||||
private final Map<String, Integer> wallGrid = new HashMap<>();
|
||||
private final List<int[]> spawnPoints;
|
||||
/** Raw static wall positions loaded from JSON / 从JSON加载的静态墙位置 */
|
||||
private final List<int[]> staticWallData = new ArrayList<>();
|
||||
/** Raw nut wall positions loaded from JSON / 从JSON加载的坚果墙位置 */
|
||||
private final List<int[]> nutWallData = new ArrayList<>();
|
||||
/** Flow field for zombie pathfinding / 僵尸寻路流场 */
|
||||
private final FlowField flowField;
|
||||
|
||||
// References to ECS component maps, set by ECSWorld after construction
|
||||
private Map<Integer, BlocksMovement> blocksMovements;
|
||||
private Map<Integer, MovementCost> movementCosts;
|
||||
|
||||
public GameMap(String mapFilePath) {
|
||||
JsonNode root = loadMapFile(mapFilePath);
|
||||
this.width = root.get("width").asInt();
|
||||
this.height = root.get("height").asInt();
|
||||
this.cells = new int[height][width];
|
||||
this.spawnPoints = new ArrayList<>();
|
||||
this.flowField = new FlowField(width, height);
|
||||
loadFromJson(root);
|
||||
}
|
||||
|
||||
/** Set ECS component map references (called by ECSWorld) */
|
||||
public void setComponentMaps(Map<Integer, BlocksMovement> bm, Map<Integer, MovementCost> mc) {
|
||||
this.blocksMovements = bm;
|
||||
this.movementCosts = mc;
|
||||
}
|
||||
|
||||
/** Register a wall entity in the spatial index */
|
||||
public void registerWall(int gx, int gy, int entityId) {
|
||||
wallGrid.put(key(gx, gy), entityId);
|
||||
}
|
||||
|
||||
/** Unregister a wall entity and invalidate flow field */
|
||||
public void unregisterWall(int gx, int gy) {
|
||||
wallGrid.remove(key(gx, gy));
|
||||
flowField.invalidate();
|
||||
}
|
||||
|
||||
/** Check if grid cell blocks movement / 检查网格格子是否阻挡移动 */
|
||||
public boolean isWall(int gx, int gy) {
|
||||
if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true;
|
||||
Integer entityId = wallGrid.get(key(gx, gy));
|
||||
if (entityId == null) return false;
|
||||
return blocksMovements != null && blocksMovements.containsKey(entityId);
|
||||
}
|
||||
|
||||
/** Check if world-space area is walkable / 检查世界坐标区域是否可行走 */
|
||||
public boolean isWalkable(float wx, float wy, float size) {
|
||||
float half = size / 2;
|
||||
float[][] corners = {
|
||||
{wx - half, wy - half}, {wx + half, wy - half},
|
||||
{wx - half, wy + half}, {wx + half, wy + half}
|
||||
};
|
||||
for (float[] c : corners) {
|
||||
int gx = (int) Math.floor(c[0]);
|
||||
int gy = (int) Math.floor(c[1]);
|
||||
if (isWall(gx, gy)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Update flow field with current player positions */
|
||||
public void updateFlowField(List<float[]> playerPositions) {
|
||||
flowField.update(wallGrid, blocksMovements, movementCosts, playerPositions);
|
||||
}
|
||||
|
||||
/** Get flow direction at world position */
|
||||
public float[] getFlowDirection(float wx, float wy) {
|
||||
return flowField.getDirection(wx, wy);
|
||||
}
|
||||
|
||||
/** Check if flow field is valid */
|
||||
public boolean isFlowFieldValid() {
|
||||
return flowField.isValid();
|
||||
}
|
||||
|
||||
/** Get cell grid with wall info (for sync) / 获取包含墙壁信息的格子网格 */
|
||||
public int[][] getCells() {
|
||||
int[][] result = new int[height][width];
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
result[y][x] = cells[y][x];
|
||||
}
|
||||
}
|
||||
// Mark wall cells from spatial index
|
||||
for (Map.Entry<String, Integer> entry : wallGrid.entrySet()) {
|
||||
String[] parts = entry.getKey().split(",");
|
||||
int x = Integer.parseInt(parts[0]);
|
||||
int y = Integer.parseInt(parts[1]);
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||
Integer entityId = entry.getValue();
|
||||
if (blocksMovements != null && blocksMovements.containsKey(entityId)) {
|
||||
// Check if it's a nut wall (has MovementCost) or static wall
|
||||
if (movementCosts != null && movementCosts.containsKey(entityId)) {
|
||||
result[y][x] = 4; // nut wall
|
||||
} else {
|
||||
result[y][x] = 1; // static wall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get player spawn points */
|
||||
public List<int[]> getSpawnPoints() {
|
||||
return spawnPoints;
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private JsonNode loadMapFile(String mapFilePath) {
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
return mapper.readTree(new File(mapFilePath));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to load map file: " + mapFilePath, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadFromJson(JsonNode root) {
|
||||
JsonNode wallsNode = root.get("walls");
|
||||
if (wallsNode != null && wallsNode.isArray()) {
|
||||
for (JsonNode wallNode : wallsNode) {
|
||||
int x = (int) wallNode.get("x").asDouble();
|
||||
int y = (int) wallNode.get("y").asDouble();
|
||||
String type = wallNode.get("type").asText();
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) continue;
|
||||
if ("static".equals(type)) {
|
||||
staticWallData.add(new int[]{x, y});
|
||||
} else if ("nut".equals(type)) {
|
||||
nutWallData.add(new int[]{x, y});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode playerSpawns = root.get("playerSpawns");
|
||||
if (playerSpawns != null && playerSpawns.isArray()) {
|
||||
for (JsonNode spawn : playerSpawns) {
|
||||
int x = spawn.get("x").asInt();
|
||||
int y = spawn.get("y").asInt();
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||
cells[y][x] = 2;
|
||||
spawnPoints.add(new int[]{x, y});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JsonNode zombieSpawns = root.get("zombieSpawns");
|
||||
if (zombieSpawns != null && zombieSpawns.isArray()) {
|
||||
for (JsonNode spawn : zombieSpawns) {
|
||||
int x = spawn.get("x").asInt();
|
||||
int y = spawn.get("y").asInt();
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||
cells[y][x] = 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Get zombie spawn points from cells / 从格子中获取僵尸出生点 */
|
||||
public List<int[]> getZombieSpawnPoints() {
|
||||
List<int[]> points = new ArrayList<>();
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
if (cells[y][x] == 3) {
|
||||
points.add(new int[]{x, y});
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private static String key(int x, int y) {
|
||||
return x + "," + y;
|
||||
}
|
||||
}
|
||||
144
backend/src/main/java/com/zombie/game/model/MapData.java
Normal file
144
backend/src/main/java/com/zombie/game/model/MapData.java
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.zombie.game.model;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/** Serializable map data for storage or network transfer / 可序列化的地图数据,用于存储或网络传输 */
|
||||
public class MapData {
|
||||
// Map unique identifier / 地图唯一标识
|
||||
private String id;
|
||||
// Map display name / 地图显示名称
|
||||
private String name;
|
||||
// Map width in grid units / 地图宽度(网格单位)
|
||||
private int width;
|
||||
// Map height in grid units / 地图高度(网格单位)
|
||||
private int height;
|
||||
// List of wall data maps / 墙壁数据映射列表
|
||||
private List<Map<String, Object>> walls;
|
||||
// List of player spawn coordinates / 玩家出生点坐标列表
|
||||
private List<Map<String, Integer>> playerSpawns;
|
||||
// List of zombie spawn coordinates / 僵尸出生点坐标列表
|
||||
private List<Map<String, Integer>> zombieSpawns;
|
||||
|
||||
// Default constructor / 默认构造函数
|
||||
public MapData() {
|
||||
this.walls = new ArrayList<>();
|
||||
this.playerSpawns = new ArrayList<>();
|
||||
this.zombieSpawns = new ArrayList<>();
|
||||
}
|
||||
|
||||
// Full constructor with all fields / 全字段构造函数
|
||||
public MapData(String id, String name, int width, int height,
|
||||
List<Map<String, Object>> walls,
|
||||
List<Map<String, Integer>> playerSpawns,
|
||||
List<Map<String, Integer>> zombieSpawns) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.walls = walls;
|
||||
this.playerSpawns = playerSpawns;
|
||||
this.zombieSpawns = zombieSpawns;
|
||||
}
|
||||
|
||||
// Get map ID / 获取地图ID
|
||||
public String getId() { return id; }
|
||||
// Set map ID / 设置地图ID
|
||||
public void setId(String id) { this.id = id; }
|
||||
// Get map name / 获取地图名称
|
||||
public String getName() { return name; }
|
||||
// Set map name / 设置地图名称
|
||||
public void setName(String name) { this.name = name; }
|
||||
// Get map width / 获取地图宽度
|
||||
public int getWidth() { return width; }
|
||||
// Set map width / 设置地图宽度
|
||||
public void setWidth(int width) { this.width = width; }
|
||||
// Get map height / 获取地图高度
|
||||
public int getHeight() { return height; }
|
||||
// Set map height / 设置地图高度
|
||||
public void setHeight(int height) { this.height = height; }
|
||||
// Get walls list / 获取墙壁列表
|
||||
public List<Map<String, Object>> getWalls() { return walls; }
|
||||
// Set walls list / 设置墙壁列表
|
||||
public void setWalls(List<Map<String, Object>> walls) { this.walls = walls; }
|
||||
// Get player spawns / 获取玩家出生点
|
||||
public List<Map<String, Integer>> getPlayerSpawns() { return playerSpawns; }
|
||||
// Set player spawns / 设置玩家出生点
|
||||
public void setPlayerSpawns(List<Map<String, Integer>> playerSpawns) { this.playerSpawns = playerSpawns; }
|
||||
// Get zombie spawns / 获取僵尸出生点
|
||||
public List<Map<String, Integer>> getZombieSpawns() { return zombieSpawns; }
|
||||
// Set zombie spawns / 设置僵尸出生点
|
||||
public void setZombieSpawns(List<Map<String, Integer>> zombieSpawns) { this.zombieSpawns = zombieSpawns; }
|
||||
|
||||
// Convert this map data to a 2D cell array / 将此地图数据转换为二维格子数组
|
||||
public int[][] toCells() {
|
||||
int[][] cells = new int[height][width];
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
cells[y][x] = 0;
|
||||
}
|
||||
}
|
||||
// Mark wall cells / 标记墙壁格子
|
||||
for (Map<String, Object> wall : walls) {
|
||||
int x = (Integer) wall.get("x");
|
||||
int y = (Integer) wall.get("y");
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||
cells[y][x] = 1;
|
||||
}
|
||||
}
|
||||
// Mark player spawn cells / 标记玩家出生点格子
|
||||
for (Map<String, Integer> spawn : playerSpawns) {
|
||||
int x = spawn.get("x");
|
||||
int y = spawn.get("y");
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||
cells[y][x] = 2;
|
||||
}
|
||||
}
|
||||
// Mark zombie spawn cells / 标记僵尸出生点格子
|
||||
for (Map<String, Integer> spawn : zombieSpawns) {
|
||||
int x = spawn.get("x");
|
||||
int y = spawn.get("y");
|
||||
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||
cells[y][x] = 3;
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// Create MapData from a GameMap instance / 从GameMap实例创建MapData
|
||||
public static MapData fromGameMap(String id, String name, GameMap map) {
|
||||
List<Map<String, Object>> walls = new ArrayList<>();
|
||||
List<Map<String, Integer>> playerSpawns = new ArrayList<>();
|
||||
List<Map<String, Integer>> zombieSpawns = new ArrayList<>();
|
||||
|
||||
// Convert static walls from raw data
|
||||
for (int[] pos : map.getStaticWallData()) {
|
||||
Map<String, Object> wallData = new HashMap<>();
|
||||
wallData.put("x", pos[0]);
|
||||
wallData.put("y", pos[1]);
|
||||
wallData.put("type", "static");
|
||||
walls.add(wallData);
|
||||
}
|
||||
|
||||
// Convert nut walls from raw data
|
||||
for (int[] pos : map.getNutWallData()) {
|
||||
Map<String, Object> wallData = new HashMap<>();
|
||||
wallData.put("x", pos[0]);
|
||||
wallData.put("y", pos[1]);
|
||||
wallData.put("type", "nut");
|
||||
walls.add(wallData);
|
||||
}
|
||||
|
||||
// Convert player spawn points
|
||||
for (int[] spawn : map.getSpawnPoints()) {
|
||||
Map<String, Integer> sp = new HashMap<>();
|
||||
sp.put("x", spawn[0]);
|
||||
sp.put("y", spawn[1]);
|
||||
playerSpawns.add(sp);
|
||||
}
|
||||
|
||||
return new MapData(id, name, map.getWidth(), map.getHeight(), walls, playerSpawns, zombieSpawns);
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/zombie/game/model/PlayerInfo.java
Normal file
26
backend/src/main/java/com/zombie/game/model/PlayerInfo.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.zombie.game.model;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/** Player information within a room / 房间内的玩家信息 */
|
||||
@Getter
|
||||
public class PlayerInfo {
|
||||
// Player unique identifier / 玩家唯一标识
|
||||
private final String id;
|
||||
// Player display name / 玩家显示名称
|
||||
private final String name;
|
||||
// Whether the player is ready / 玩家是否已准备
|
||||
private boolean ready;
|
||||
|
||||
// Create a new player info / 创建新的玩家信息
|
||||
public PlayerInfo(String id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.ready = false;
|
||||
}
|
||||
|
||||
// Set the ready state / 设置准备状态
|
||||
public void setReady(boolean ready) {
|
||||
this.ready = ready;
|
||||
}
|
||||
}
|
||||
106
backend/src/main/java/com/zombie/game/model/Room.java
Normal file
106
backend/src/main/java/com/zombie/game/model/Room.java
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.zombie.game.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import java.util.*;
|
||||
|
||||
import static com.zombie.game.model.Constants.*;
|
||||
|
||||
/** Game room holding players and game state / 游戏房间,管理玩家和游戏状态 */
|
||||
@Getter
|
||||
public class Room {
|
||||
// Room unique identifier / 房间唯一标识
|
||||
private String id;
|
||||
// Host player ID / 房主玩家ID
|
||||
private String hostId;
|
||||
// Map of playerId to PlayerInfo / 玩家ID到玩家信息的映射
|
||||
private Map<String, PlayerInfo> players;
|
||||
// Whether the game has started / 游戏是否已开始
|
||||
private boolean gameStarted;
|
||||
// Maximum number of players allowed / 最大玩家数量
|
||||
private final int maxPlayers = 4;
|
||||
|
||||
// Set game started flag / 设置游戏开始标志
|
||||
public void setGameStarted(boolean started) { this.gameStarted = started; }
|
||||
|
||||
// Create a new room with the host player / 创建新房间并添加房主
|
||||
public Room(String id, String hostId, String hostName) {
|
||||
this.id = id;
|
||||
this.hostId = hostId;
|
||||
this.players = new LinkedHashMap<>();
|
||||
players.put(hostId, new PlayerInfo(hostId, hostName));
|
||||
}
|
||||
|
||||
// Add a player to the room / 添加玩家到房间
|
||||
public boolean addPlayer(String playerId, String playerName) {
|
||||
if (players.size() >= maxPlayers) return false;
|
||||
if (players.containsKey(playerId)) return false;
|
||||
players.put(playerId, new PlayerInfo(playerId, playerName));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove a player from the room / 从房间移除玩家
|
||||
public void removePlayer(String playerId) {
|
||||
players.remove(playerId);
|
||||
}
|
||||
|
||||
// Get player by ID / 根据ID获取玩家
|
||||
public PlayerInfo getPlayer(String playerId) {
|
||||
return players.get(playerId);
|
||||
}
|
||||
|
||||
// Get all players / 获取所有玩家
|
||||
public Collection<PlayerInfo> getPlayers() {
|
||||
return players.values();
|
||||
}
|
||||
|
||||
// Get current player count / 获取当前玩家数量
|
||||
public int getPlayerCount() {
|
||||
return players.size();
|
||||
}
|
||||
|
||||
// Check if a player is the host / 检查玩家是否为房主
|
||||
public boolean isHost(String playerId) {
|
||||
return hostId.equals(playerId);
|
||||
}
|
||||
|
||||
// Check if all non-host players are ready / 检查所有非房主玩家是否已准备
|
||||
public boolean allReady() {
|
||||
for (PlayerInfo p : players.values()) {
|
||||
if (!p.getId().equals(hostId) && !p.isReady()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Convert room state to a map for a specific player / 将房间状态转换为指定玩家的Map
|
||||
public Map<String, Object> toStateMap(String playerId) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("roomId", id);
|
||||
map.put("hostId", hostId);
|
||||
map.put("isHost", hostId.equals(playerId));
|
||||
map.put("playerId", playerId);
|
||||
|
||||
// Build player list with index / 构建带索引的玩家列表
|
||||
List<Map<String, Object>> playerList = new ArrayList<>();
|
||||
int index = 0;
|
||||
for (PlayerInfo p : players.values()) {
|
||||
Map<String, Object> pm = new LinkedHashMap<>();
|
||||
pm.put("id", p.getId());
|
||||
pm.put("name", p.getName());
|
||||
pm.put("ready", p.isReady());
|
||||
pm.put("index", index++);
|
||||
playerList.add(pm);
|
||||
}
|
||||
map.put("players", playerList);
|
||||
return map;
|
||||
}
|
||||
|
||||
// Convert to a lightweight room list entry / 转换为轻量级房间列表条目
|
||||
public Map<String, Object> toRoomListMap() {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", id);
|
||||
PlayerInfo host = players.get(hostId);
|
||||
map.put("hostName", host != null ? host.getName() : "Unknown");
|
||||
map.put("playerCount", players.size());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
73
backend/src/main/java/com/zombie/game/server/GameLoop.java
Normal file
73
backend/src/main/java/com/zombie/game/server/GameLoop.java
Normal file
@@ -0,0 +1,73 @@
|
||||
package com.zombie.game.server;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Game loop running at fixed tick rate for a single room / 以固定帧率运行的游戏循环,为单个房间服务
|
||||
*/
|
||||
public class GameLoop {
|
||||
// Room ID this loop serves / 该循环服务的房间ID
|
||||
private String roomId;
|
||||
// The ECS world being updated / 正在更新的ECS世界
|
||||
private ECSWorld world;
|
||||
// Callback to broadcast state each tick / 每帧广播状态的回调
|
||||
private GameService.GameStateBroadcast broadcaster;
|
||||
// Whether the loop is currently running / 循环当前是否正在运行
|
||||
private volatile boolean running;
|
||||
// Target tick rate (30 ticks per second) / 目标帧率(每秒30帧)
|
||||
private static final int TICK_RATE = 30;
|
||||
// Interval between ticks in milliseconds / 帧间隔(毫秒)
|
||||
private static final long TICK_INTERVAL_MS = 1000 / TICK_RATE;
|
||||
// Interval between ticks in seconds / 帧间隔(秒)
|
||||
private static final float TICK_INTERVAL_SEC = 1.0f / TICK_RATE;
|
||||
// Scheduler for running the loop / 运行循环的调度器
|
||||
private ScheduledExecutorService scheduler;
|
||||
|
||||
// Constructor: sets up the game loop for a room / 构造函数:为房间设置游戏循环
|
||||
public GameLoop(String roomId, ECSWorld world, GameService.GameStateBroadcast broadcaster) {
|
||||
this.roomId = roomId;
|
||||
this.world = world;
|
||||
this.broadcaster = broadcaster;
|
||||
this.running = true;
|
||||
// Create single-threaded daemon scheduler / 创建单线程守护调度器
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "GameLoop-" + roomId);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
|
||||
// Start the game loop / 启动游戏循环
|
||||
public void start() {
|
||||
scheduler.scheduleAtFixedRate(this::tick, 0, TICK_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
// Single tick: update world and broadcast state / 单帧:更新世界并广播状态
|
||||
private void tick() {
|
||||
if (!running) return;
|
||||
synchronized (world.getLock()) {
|
||||
world.update(TICK_INTERVAL_SEC);
|
||||
}
|
||||
broadcaster.broadcast(roomId, world);
|
||||
}
|
||||
|
||||
// Stop the game loop / 停止游戏循环
|
||||
public void stop() {
|
||||
running = false;
|
||||
if (scheduler != null && !scheduler.isShutdown()) {
|
||||
scheduler.shutdown();
|
||||
try {
|
||||
// Force shutdown if graceful shutdown fails / 优雅关闭失败则强制关闭
|
||||
if (!scheduler.awaitTermination(1, TimeUnit.SECONDS)) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
163
backend/src/main/java/com/zombie/game/server/GameService.java
Normal file
163
backend/src/main/java/com/zombie/game/server/GameService.java
Normal file
@@ -0,0 +1,163 @@
|
||||
package com.zombie.game.server;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.ecs.components.*;
|
||||
import com.zombie.game.model.*;
|
||||
import com.zombie.game.systems.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Core game service managing game lifecycle and state / 核心游戏服务,管理游戏生命周期和状态
|
||||
*/
|
||||
public class GameService {
|
||||
// Logger for game events / 游戏事件日志记录器
|
||||
private static final Logger logger = LoggerFactory.getLogger(GameService.class);
|
||||
|
||||
// Active game worlds mapped by room ID / 活跃的游戏世界,以房间ID映射
|
||||
private final Map<String, ECSWorld> activeGames = new ConcurrentHashMap<>();
|
||||
// Game loops mapped by room ID / 游戏循环,以房间ID映射
|
||||
private final Map<String, GameLoop> gameLoops = new ConcurrentHashMap<>();
|
||||
// Callback for broadcasting game state / 广播游戏状态的回调
|
||||
private final GameStateBroadcast broadcaster;
|
||||
|
||||
// Interface for broadcasting game state to clients / 向客户端广播游戏状态的接口
|
||||
public interface GameStateBroadcast {
|
||||
void broadcast(String roomId, ECSWorld world);
|
||||
}
|
||||
|
||||
// Constructor with broadcast callback / 带广播回调的构造函数
|
||||
public GameService(GameStateBroadcast broadcaster) {
|
||||
this.broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
// Start a new game for the given room / 为指定房间启动新游戏
|
||||
public Map<String, Map<String, Object>> startGame(Room room) {
|
||||
// Load map from system property or default / 从系统属性或默认路径加载地图
|
||||
String mapPath = System.getProperty("map.path", "maps/d540209a.json");
|
||||
ECSWorld world = new ECSWorld(mapPath);
|
||||
|
||||
// Add systems in order: input -> flow field -> zombie spawn -> zombie movement
|
||||
world.addSystem(new PlayerInputSystem());
|
||||
world.addSystem(new FlowFieldUpdateSystem());
|
||||
world.addSystem(new ZombieSpawnSystem());
|
||||
world.addSystem(new ZombieMovementSystem());
|
||||
|
||||
// Spawn players at map spawn points / 在地图出生点生成玩家
|
||||
int index = 0;
|
||||
List<int[]> spawnPoints = world.getMap().getSpawnPoints();
|
||||
|
||||
for (PlayerInfo playerInfo : room.getPlayers()) {
|
||||
int[] sp = spawnPoints.get(index % spawnPoints.size());
|
||||
float wx = sp[0] + 0.5f;
|
||||
float wy = sp[1] + 0.5f;
|
||||
world.createPlayerEntity(playerInfo.getId(), playerInfo.getName(), wx, wy);
|
||||
index++;
|
||||
}
|
||||
|
||||
// Spawn test zombies at zombie spawn points / 在僵尸出生点生成测试僵尸
|
||||
List<int[]> zombieSpawns = world.getMap().getZombieSpawnPoints();
|
||||
for (int[] zsp : zombieSpawns) {
|
||||
int zombieId = world.createZombieEntity(zsp[0] + 0.5f, zsp[1] + 0.5f);
|
||||
logger.info("Initial zombie created: id={} at ({}, {})", zombieId, zsp[0], zsp[1]);
|
||||
}
|
||||
|
||||
activeGames.put(room.getId(), world);
|
||||
|
||||
// Build initial data for each player / 为每位玩家构建初始数据
|
||||
Map<String, Map<String, Object>> playerInitData = new LinkedHashMap<>();
|
||||
for (PlayerInfo playerInfo : room.getPlayers()) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("playerId", playerInfo.getId());
|
||||
data.put("mapData", serializeMapData(world.getMapData()));
|
||||
|
||||
// Build player list with positions / 构建包含位置的玩家列表
|
||||
List<Map<String, Object>> playerList = new ArrayList<>();
|
||||
int idx = 0;
|
||||
for (PlayerInfo p : room.getPlayers()) {
|
||||
Integer entityId = world.getPlayerEntity(p.getId());
|
||||
Map<String, Object> pm = new LinkedHashMap<>();
|
||||
pm.put("id", p.getId());
|
||||
pm.put("name", p.getName());
|
||||
if (entityId != null) {
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
if (pos != null) {
|
||||
pm.put("x", pos.getX());
|
||||
pm.put("y", pos.getY());
|
||||
}
|
||||
}
|
||||
pm.put("index", idx++);
|
||||
playerList.add(pm);
|
||||
}
|
||||
data.put("players", playerList);
|
||||
playerInitData.put(playerInfo.getId(), data);
|
||||
}
|
||||
|
||||
// Create game loop (not started yet) / 创建游戏循环(尚未启动)
|
||||
GameLoop loop = new GameLoop(room.getId(), world, broadcaster);
|
||||
gameLoops.put(room.getId(), loop);
|
||||
|
||||
logger.info("Game initialized for room: {}", room.getId());
|
||||
return playerInitData;
|
||||
}
|
||||
|
||||
// Start the game loop for a room (call after sending game_started) / 启动房间的游戏循环(在发送 game_started 之后调用)
|
||||
public void startGameLoop(String roomId) {
|
||||
GameLoop loop = gameLoops.get(roomId);
|
||||
if (loop != null) {
|
||||
loop.start();
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the game for a room / 停止指定房间的游戏
|
||||
public void stopGame(String roomId) {
|
||||
GameLoop loop = gameLoops.remove(roomId);
|
||||
if (loop != null) {
|
||||
loop.stop();
|
||||
}
|
||||
activeGames.remove(roomId);
|
||||
logger.info("Game stopped for room: {}", roomId);
|
||||
}
|
||||
|
||||
// Process player input for movement and aiming / 处理玩家输入(移动和瞄准)
|
||||
public void processPlayerInput(String roomId, String playerId,
|
||||
float dx, float dy, float aimX, float aimY, int seq) {
|
||||
ECSWorld world = activeGames.get(roomId);
|
||||
if (world == null) return;
|
||||
|
||||
synchronized (world.getLock()) {
|
||||
Integer entityId = world.getPlayerEntity(playerId);
|
||||
if (entityId == null) return;
|
||||
|
||||
// Skip if player is dead / 如果玩家已死亡则跳过
|
||||
Health health = world.getHealths().get(entityId);
|
||||
if (health == null || !health.isAlive()) return;
|
||||
|
||||
// Apply input to player entity / 将输入应用到玩家实体
|
||||
PlayerInput input = world.getPlayerInputs().get(entityId);
|
||||
if (input == null) return;
|
||||
|
||||
input.setDx(dx);
|
||||
input.setDy(dy);
|
||||
input.setAimX(aimX);
|
||||
input.setAimY(aimY);
|
||||
input.setSeq(seq);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert 2D int array to nested list for JSON serialization / 将二维整数数组转换为嵌套列表用于JSON序列化
|
||||
private List<List<Integer>> serializeMapData(int[][] cells) {
|
||||
List<List<Integer>> result = new ArrayList<>();
|
||||
for (int[] row : cells) {
|
||||
List<Integer> rowList = new ArrayList<>();
|
||||
for (int cell : row) {
|
||||
rowList.add(cell);
|
||||
}
|
||||
result.add(rowList);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package com.zombie.game.server;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.model.*;
|
||||
import com.zombie.game.model.Constants;
|
||||
import com.zombie.game.systems.StateSyncSystem;
|
||||
import org.java_websocket.WebSocket;
|
||||
import org.java_websocket.handshake.ClientHandshake;
|
||||
import org.java_websocket.server.WebSocketServer;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Main WebSocket server handling all client connections / 主WebSocket服务器,处理所有客户端连接
|
||||
* Manages game rooms, player connections, and message routing / 管理游戏房间、玩家连接和消息路由
|
||||
*/
|
||||
public class GameWebSocketServer extends WebSocketServer {
|
||||
// SLF4J logger for server events / SLF4J日志记录器,用于服务器事件
|
||||
private static final Logger logger = LoggerFactory.getLogger(GameWebSocketServer.class);
|
||||
// JSON serializer/deserializer / JSON序列化/反序列化工具
|
||||
private Gson gson;
|
||||
// Room manager for creating and tracking game rooms / 房间管理器,用于创建和追踪游戏房间
|
||||
private RoomManager roomManager;
|
||||
// Game service for game logic / 游戏服务,用于游戏逻辑处理
|
||||
private GameService gameService;
|
||||
// Map connection to player ID / 连接映射到玩家ID
|
||||
private Map<WebSocket, String> connectionToPlayer;
|
||||
// Map player ID to connection / 玩家ID映射到连接
|
||||
private Map<String, WebSocket> playerToConnection;
|
||||
// Timer for broadcasting room list periodically / 定时器,用于定期广播房间列表
|
||||
private Timer roomListTimer;
|
||||
|
||||
// Constructor: initializes server with given port / 构造函数:用指定端口初始化服务器
|
||||
public GameWebSocketServer(int port) {
|
||||
super(new InetSocketAddress(port));
|
||||
this.gson = new Gson();
|
||||
this.roomManager = new RoomManager();
|
||||
this.connectionToPlayer = new ConcurrentHashMap<>();
|
||||
this.playerToConnection = new ConcurrentHashMap<>();
|
||||
this.gameService = new GameService(this::broadcastGameState);
|
||||
}
|
||||
|
||||
// Called when a new client connects / 新客户端连接时调用
|
||||
@Override
|
||||
public void onOpen(WebSocket conn, ClientHandshake handshake) {
|
||||
logger.info("New connection: {}", conn.getRemoteSocketAddress());
|
||||
}
|
||||
|
||||
// Called when a client disconnects / 客户端断开连接时调用
|
||||
@Override
|
||||
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
|
||||
logger.info("Connection closed: {}", conn.getRemoteSocketAddress());
|
||||
// Remove player mapping and handle room leave / 移除玩家映射并处理离开房间
|
||||
String playerId = connectionToPlayer.remove(conn);
|
||||
if (playerId != null) {
|
||||
playerToConnection.remove(playerId);
|
||||
handleLeaveRoomByPlayerId(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Called when a message is received from a client / 收到客户端消息时调用
|
||||
@Override
|
||||
public void onMessage(WebSocket conn, String message) {
|
||||
try {
|
||||
// Parse message type and data / 解析消息类型和数据
|
||||
JsonObject msg = gson.fromJson(message, JsonObject.class);
|
||||
String type = msg.get("type").getAsString();
|
||||
JsonObject data = msg.has("data") ? msg.getAsJsonObject("data") : new JsonObject();
|
||||
|
||||
// Route message to appropriate handler / 将消息路由到对应的处理函数
|
||||
switch (type) {
|
||||
case Constants.MSG_CREATE_ROOM:
|
||||
handleCreateRoom(conn, data);
|
||||
break;
|
||||
case Constants.MSG_JOIN_ROOM:
|
||||
handleJoinRoom(conn, data);
|
||||
break;
|
||||
case Constants.MSG_LEAVE_ROOM:
|
||||
handleLeaveRoomByConn(conn);
|
||||
break;
|
||||
case Constants.MSG_ROOM_LIST:
|
||||
handleRoomList(conn);
|
||||
break;
|
||||
case Constants.MSG_READY:
|
||||
handleReady(conn);
|
||||
break;
|
||||
case Constants.MSG_START_GAME:
|
||||
handleStartGame(conn);
|
||||
break;
|
||||
case Constants.MSG_PLAYER_INPUT:
|
||||
handlePlayerInput(conn, data);
|
||||
break;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("Error processing message from {}", conn.getRemoteSocketAddress(), e);
|
||||
sendError(conn, "Invalid message format");
|
||||
}
|
||||
}
|
||||
|
||||
// Called when a WebSocket error occurs / WebSocket错误发生时调用
|
||||
@Override
|
||||
public void onError(WebSocket conn, Exception ex) {
|
||||
logger.error("WebSocket error on {}", conn != null ? conn.getRemoteSocketAddress() : "null", ex);
|
||||
}
|
||||
|
||||
// Called when the server starts successfully / 服务器成功启动时调用
|
||||
@Override
|
||||
public void onStart() {
|
||||
logger.info("Game WebSocket Server started on port {}", getPort());
|
||||
// Start periodic room list broadcast (every 2 seconds) / 启动定期房间列表广播(每2秒)
|
||||
roomListTimer = new Timer(true);
|
||||
roomListTimer.scheduleAtFixedRate(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
broadcastRoomList();
|
||||
}
|
||||
}, 0, 2000);
|
||||
}
|
||||
|
||||
// Handle create room request / 处理创建房间请求
|
||||
private void handleCreateRoom(WebSocket conn, JsonObject data) {
|
||||
// Validate required fields / 验证必需字段
|
||||
if (!MessageUtils.hasRequired(data, "playerName")) {
|
||||
sendError(conn, "Missing playerName");
|
||||
return;
|
||||
}
|
||||
String playerName = MessageUtils.getString(data, "playerName");
|
||||
// Generate unique IDs for player and room / 为玩家和房间生成唯一ID
|
||||
String playerId = UUID.randomUUID().toString();
|
||||
String roomId = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
// Track connection mappings / 记录连接映射
|
||||
connectionToPlayer.put(conn, playerId);
|
||||
playerToConnection.put(playerId, conn);
|
||||
|
||||
// Create new room with player as host / 创建新房间,玩家作为房主
|
||||
Room room = new Room(roomId, playerId, playerName);
|
||||
roomManager.addRoom(roomId, room);
|
||||
|
||||
// Send room state back to creator / 向创建者发送房间状态
|
||||
sendToConnection(conn, Constants.MSG_ROOM_STATE, room.toStateMap(playerId));
|
||||
logger.info("Room created: {} by {}", roomId, playerName);
|
||||
}
|
||||
|
||||
// Handle join room request / 处理加入房间请求
|
||||
private void handleJoinRoom(WebSocket conn, JsonObject data) {
|
||||
// Validate required fields / 验证必需字段
|
||||
if (!MessageUtils.hasRequired(data, "roomId", "playerName")) {
|
||||
sendError(conn, "Missing roomId or playerName");
|
||||
return;
|
||||
}
|
||||
String roomId = MessageUtils.getString(data, "roomId");
|
||||
String playerName = MessageUtils.getString(data, "playerName");
|
||||
|
||||
Room room = roomManager.getRoom(roomId);
|
||||
if (room == null) {
|
||||
sendError(conn, "Room not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if room is full (max 4 players) / 检查房间是否已满(最多4人)
|
||||
if (room.getPlayerCount() >= 4) {
|
||||
sendError(conn, "Room is full");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate player ID and track connection / 生成玩家ID并追踪连接
|
||||
String playerId = UUID.randomUUID().toString();
|
||||
connectionToPlayer.put(conn, playerId);
|
||||
playerToConnection.put(playerId, conn);
|
||||
|
||||
// Add player to room / 将玩家添加到房间
|
||||
room.addPlayer(playerId, playerName);
|
||||
roomManager.joinRoom(roomId, playerId, playerName);
|
||||
|
||||
// Notify all players of updated room state / 通知所有玩家房间状态已更新
|
||||
broadcastRoomState(room);
|
||||
logger.info("Player {} joined room {}", playerName, roomId);
|
||||
}
|
||||
|
||||
// Handle leave room by connection / 通过连接处理离开房间
|
||||
private void handleLeaveRoomByConn(WebSocket conn) {
|
||||
String playerId = connectionToPlayer.get(conn);
|
||||
if (playerId == null) return;
|
||||
handleLeaveRoomByPlayerId(playerId);
|
||||
}
|
||||
|
||||
// Handle leave room by player ID / 通过玩家ID处理离开房间
|
||||
private void handleLeaveRoomByPlayerId(String playerId) {
|
||||
Room room = roomManager.leaveRoom(playerId);
|
||||
if (room == null) return;
|
||||
|
||||
Room updatedRoom = roomManager.getRoom(room.getId());
|
||||
if (updatedRoom == null) {
|
||||
// Room was deleted, stop the game / 房间被删除,停止游戏
|
||||
gameService.stopGame(room.getId());
|
||||
} else {
|
||||
// Notify remaining players / 通知剩余玩家
|
||||
broadcastRoomState(updatedRoom);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle room list request / 处理房间列表请求
|
||||
private void handleRoomList(WebSocket conn) {
|
||||
List<Map<String, Object>> roomList = new ArrayList<>();
|
||||
for (Room room : roomManager.getAvailableRooms()) {
|
||||
roomList.add(room.toRoomListMap());
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("rooms", roomList);
|
||||
sendToConnection(conn, Constants.MSG_ROOM_LIST, data);
|
||||
}
|
||||
|
||||
// Handle player ready toggle / 处理玩家准备状态切换
|
||||
private void handleReady(WebSocket conn) {
|
||||
String playerId = connectionToPlayer.get(conn);
|
||||
if (playerId == null) return;
|
||||
|
||||
Room room = roomManager.getRoomByPlayerId(playerId);
|
||||
if (room == null) return;
|
||||
|
||||
PlayerInfo player = room.getPlayer(playerId);
|
||||
if (player != null) {
|
||||
// Toggle ready state / 切换准备状态
|
||||
player.setReady(!player.isReady());
|
||||
broadcastRoomState(room);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle start game request (host only) / 处理开始游戏请求(仅房主)
|
||||
private void handleStartGame(WebSocket conn) {
|
||||
String playerId = connectionToPlayer.get(conn);
|
||||
if (playerId == null) {
|
||||
sendError(conn, "Not in a room / 未在房间中");
|
||||
return;
|
||||
}
|
||||
|
||||
Room room = roomManager.getRoomByPlayerId(playerId);
|
||||
if (room == null) {
|
||||
sendError(conn, "Room not found / 房间不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room.isHost(playerId)) {
|
||||
sendError(conn, "Only host can start game / 只有房主可以开始游戏");
|
||||
return;
|
||||
}
|
||||
if (room.isGameStarted()) {
|
||||
sendError(conn, "Game already started / 游戏已经开始");
|
||||
return;
|
||||
}
|
||||
if (!room.allReady()) {
|
||||
sendError(conn, "Not all players are ready / 不是所有玩家都已准备");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set game started flag after startGame succeeds / startGame 成功后再设置标志
|
||||
try {
|
||||
startGame(room);
|
||||
room.setGameStarted(true);
|
||||
} catch (Exception e) {
|
||||
logger.error("Failed to start game for room {}", room.getId(), e);
|
||||
sendError(conn, "Failed to start game: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Start the game for a room / 为房间启动游戏
|
||||
private void startGame(Room room) {
|
||||
// Initialize game world and create player entities / 初始化游戏世界并创建玩家实体
|
||||
Map<String, Map<String, Object>> playerInitData = gameService.startGame(room);
|
||||
|
||||
// Send initial game data to each player / 向每位玩家发送初始游戏数据
|
||||
for (Map.Entry<String, Map<String, Object>> entry : playerInitData.entrySet()) {
|
||||
WebSocket pConn = playerToConnection.get(entry.getKey());
|
||||
if (pConn != null) {
|
||||
sendToConnection(pConn, Constants.MSG_GAME_STARTED, entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
// Start game loop after game_started is sent / game_started 发送完毕后再启动游戏循环
|
||||
gameService.startGameLoop(room.getId());
|
||||
}
|
||||
|
||||
// Handle player input (movement, aim) / 处理玩家输入(移动、瞄准)
|
||||
private void handlePlayerInput(WebSocket conn, JsonObject data) {
|
||||
String playerId = connectionToPlayer.get(conn);
|
||||
if (playerId == null) return;
|
||||
|
||||
Room room = roomManager.getRoomByPlayerId(playerId);
|
||||
if (room == null || !room.isGameStarted()) return;
|
||||
|
||||
// Extract input data / 提取输入数据
|
||||
float dx = MessageUtils.getFloat(data, "dx", 0);
|
||||
float dy = MessageUtils.getFloat(data, "dy", 0);
|
||||
float aimX = MessageUtils.getFloat(data, "aimX", 0);
|
||||
float aimY = MessageUtils.getFloat(data, "aimY", 0);
|
||||
int seq = MessageUtils.getInt(data, "seq", 0);
|
||||
|
||||
gameService.processPlayerInput(room.getId(), playerId, dx, dy, aimX, aimY, seq);
|
||||
}
|
||||
|
||||
// Broadcast game state to all players in a room / 向房间内所有玩家广播游戏状态
|
||||
private void broadcastGameState(String roomId, ECSWorld world) {
|
||||
Room room = roomManager.getRoom(roomId);
|
||||
if (room == null) return;
|
||||
|
||||
StateSyncSystem syncSystem = new StateSyncSystem();
|
||||
|
||||
// Sync state under world lock / 在世界锁下同步状态
|
||||
synchronized (world.getLock()) {
|
||||
Map<String, Object> state = syncSystem.buildGameState(world);
|
||||
for (PlayerInfo playerInfo : room.getPlayers()) {
|
||||
WebSocket pConn = playerToConnection.get(playerInfo.getId());
|
||||
if (pConn != null && pConn.isOpen()) {
|
||||
sendToConnection(pConn, Constants.MSG_GAME_STATE, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast room state to all players in a room / 向房间内所有玩家广播房间状态
|
||||
private void broadcastRoomState(Room room) {
|
||||
for (PlayerInfo player : room.getPlayers()) {
|
||||
WebSocket pConn = playerToConnection.get(player.getId());
|
||||
if (pConn != null && pConn.isOpen()) {
|
||||
sendToConnection(pConn, Constants.MSG_ROOM_STATE, room.toStateMap(player.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast room list to all unconnected clients / 向所有未连接的玩家广播房间列表
|
||||
private void broadcastRoomList() {
|
||||
List<Map<String, Object>> roomList = new ArrayList<>();
|
||||
for (Room room : roomManager.getAvailableRooms()) {
|
||||
roomList.add(room.toRoomListMap());
|
||||
}
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("rooms", roomList);
|
||||
|
||||
// Only send to clients not in a room / 只发送给不在房间中的客户端
|
||||
for (WebSocket conn : getConnections()) {
|
||||
if (connectionToPlayer.get(conn) == null) {
|
||||
sendToConnection(conn, Constants.MSG_ROOM_LIST, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send a message to a specific connection / 向指定连接发送消息
|
||||
private void sendToConnection(WebSocket conn, String type, Object data) {
|
||||
if (conn != null && conn.isOpen()) {
|
||||
Map<String, Object> msg = new LinkedHashMap<>();
|
||||
msg.put("type", type);
|
||||
msg.put("data", data);
|
||||
conn.send(gson.toJson(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// Send error message to a connection / 向连接发送错误消息
|
||||
private void sendError(WebSocket conn, String message) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("message", message);
|
||||
sendToConnection(conn, Constants.MSG_ERROR, data);
|
||||
}
|
||||
}
|
||||
136
backend/src/main/java/com/zombie/game/server/MapStorage.java
Normal file
136
backend/src/main/java/com/zombie/game/server/MapStorage.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package com.zombie.game.server;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.zombie.game.model.MapData;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Storage service for game maps (save, load, list, delete) / 地图存储服务(保存、加载、列出、删除)
|
||||
*/
|
||||
public class MapStorage {
|
||||
// Logger for map operations / 地图操作日志记录器
|
||||
private static final Logger logger = LoggerFactory.getLogger(MapStorage.class);
|
||||
// Directory where map files are stored / 地图文件存储目录
|
||||
private static final String MAPS_DIR = "maps";
|
||||
// JSON serializer/deserializer / JSON序列化/反序列化工具
|
||||
private final Gson gson;
|
||||
// In-memory cache of loaded maps / 已加载地图的内存缓存
|
||||
private final Map<String, MapData> cache;
|
||||
|
||||
// Constructor: initializes storage and ensures maps directory exists / 构造函数:初始化存储并确保地图目录存在
|
||||
public MapStorage() {
|
||||
this.gson = new Gson();
|
||||
this.cache = new HashMap<>();
|
||||
ensureMapsDir();
|
||||
}
|
||||
|
||||
// Create maps directory if it doesn't exist / 如果地图目录不存在则创建
|
||||
private void ensureMapsDir() {
|
||||
Path mapsPath = Paths.get(MAPS_DIR);
|
||||
if (!Files.exists(mapsPath)) {
|
||||
try {
|
||||
Files.createDirectories(mapsPath);
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to create maps directory", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save a map to disk and cache / 将地图保存到磁盘并缓存
|
||||
public MapData save(MapData mapData) {
|
||||
// Assign ID if missing / 如果缺少ID则生成
|
||||
if (mapData.getId() == null || mapData.getId().isEmpty()) {
|
||||
mapData.setId(UUID.randomUUID().toString().substring(0, 8));
|
||||
}
|
||||
// Set default name if missing / 如果缺少名称则使用默认值
|
||||
if (mapData.getName() == null || mapData.getName().isEmpty()) {
|
||||
mapData.setName("untitled");
|
||||
}
|
||||
|
||||
String filename = mapData.getId() + ".json";
|
||||
Path filePath = Paths.get(MAPS_DIR, filename);
|
||||
|
||||
try (Writer writer = Files.newBufferedWriter(filePath)) {
|
||||
gson.toJson(mapData, writer);
|
||||
cache.put(mapData.getId(), mapData);
|
||||
logger.info("Map saved: {} ({})", mapData.getName(), mapData.getId());
|
||||
return mapData;
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to save map: {}", mapData.getId(), e);
|
||||
throw new RuntimeException("Failed to save map", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load a map from cache or disk / 从缓存或磁盘加载地图
|
||||
public MapData load(String id) {
|
||||
// Check cache first / 先检查缓存
|
||||
MapData cached = cache.get(id);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
String filename = id + ".json";
|
||||
Path filePath = Paths.get(MAPS_DIR, filename);
|
||||
|
||||
if (!Files.exists(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(filePath)) {
|
||||
MapData mapData = gson.fromJson(reader, MapData.class);
|
||||
cache.put(id, mapData);
|
||||
return mapData;
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to load map: {}", id, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// List all maps from disk / 列出磁盘上的所有地图
|
||||
public List<MapData> listMaps() {
|
||||
List<MapData> maps = new ArrayList<>();
|
||||
Path mapsPath = Paths.get(MAPS_DIR);
|
||||
|
||||
if (!Files.exists(mapsPath)) {
|
||||
return maps;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(mapsPath, "*.json")) {
|
||||
for (Path file : stream) {
|
||||
try (Reader reader = Files.newBufferedReader(file)) {
|
||||
MapData mapData = gson.fromJson(reader, MapData.class);
|
||||
maps.add(mapData);
|
||||
} catch (IOException e) {
|
||||
logger.warn("Failed to read map file: {}", file);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to list maps", e);
|
||||
}
|
||||
|
||||
return maps;
|
||||
}
|
||||
|
||||
// Delete a map from disk and cache / 从磁盘和缓存中删除地图
|
||||
public boolean delete(String id) {
|
||||
String filename = id + ".json";
|
||||
Path filePath = Paths.get(MAPS_DIR, filename);
|
||||
|
||||
try {
|
||||
boolean deleted = Files.deleteIfExists(filePath);
|
||||
if (deleted) {
|
||||
cache.remove(id);
|
||||
logger.info("Map deleted: {}", id);
|
||||
}
|
||||
return deleted;
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to delete map: {}", id, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.zombie.game.server;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* Utility class for extracting values from JSON messages / 工具类,用于从JSON消息中提取值
|
||||
*/
|
||||
public class MessageUtils {
|
||||
|
||||
// Get string value from JSON with default / 从JSON获取字符串值,带默认值
|
||||
public static String getString(JsonObject data, String key, String defaultValue) {
|
||||
if (data == null || !data.has(key) || data.get(key).isJsonNull()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return data.get(key).getAsString();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get string value from JSON (null default) / 从JSON获取字符串值(默认null)
|
||||
public static String getString(JsonObject data, String key) {
|
||||
return getString(data, key, null);
|
||||
}
|
||||
|
||||
// Get float value from JSON with default / 从JSON获取浮点数值,带默认值
|
||||
public static float getFloat(JsonObject data, String key, float defaultValue) {
|
||||
if (data == null || !data.has(key) || data.get(key).isJsonNull()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return data.get(key).getAsFloat();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get int value from JSON with default / 从JSON获取整数值,带默认值
|
||||
public static int getInt(JsonObject data, String key, int defaultValue) {
|
||||
if (data == null || !data.has(key) || data.get(key).isJsonNull()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return data.get(key).getAsInt();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get boolean value from JSON with default / 从JSON获取布尔值,带默认值
|
||||
public static boolean getBoolean(JsonObject data, String key, boolean defaultValue) {
|
||||
if (data == null || !data.has(key) || data.get(key).isJsonNull()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return data.get(key).getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all required keys are present and non-null / 检查所有必需键是否存在且非空
|
||||
public static boolean hasRequired(JsonObject data, String... keys) {
|
||||
if (data == null) return false;
|
||||
for (String key : keys) {
|
||||
if (!data.has(key) || data.get(key).isJsonNull()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
103
backend/src/main/java/com/zombie/game/server/RoomManager.java
Normal file
103
backend/src/main/java/com/zombie/game/server/RoomManager.java
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.zombie.game.server;
|
||||
|
||||
import com.zombie.game.model.PlayerInfo;
|
||||
import com.zombie.game.model.Room;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Room manager for tracking game rooms and player-room assignments / 房间管理器,用于追踪游戏房间和玩家房间分配
|
||||
*/
|
||||
public class RoomManager {
|
||||
// All active rooms mapped by room ID / 所有活跃房间,以房间ID映射
|
||||
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||
// Player ID to room ID mapping / 玩家ID到房间ID的映射
|
||||
private final Map<String, String> playerToRoom = new ConcurrentHashMap<>();
|
||||
|
||||
// Add a new room to the manager / 向管理器添加新房间
|
||||
public void addRoom(String roomId, Room room) {
|
||||
rooms.put(roomId, room);
|
||||
// Map existing players in room to room ID / 将房间中现有玩家映射到房间ID
|
||||
for (PlayerInfo player : room.getPlayers()) {
|
||||
playerToRoom.put(player.getId(), roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a player to a room / 将玩家添加到房间
|
||||
public boolean joinRoom(String roomId, String playerId, String playerName) {
|
||||
Room room = rooms.get(roomId);
|
||||
if (room == null) return false;
|
||||
// Reject if room is full / 房间已满则拒绝
|
||||
if (room.getPlayerCount() >= 4) return false;
|
||||
|
||||
boolean added = room.addPlayer(playerId, playerName);
|
||||
if (added) {
|
||||
playerToRoom.put(playerId, roomId);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// Remove a player from their room / 从房间中移除玩家
|
||||
public Room leaveRoom(String playerId) {
|
||||
String roomId = playerToRoom.remove(playerId);
|
||||
if (roomId == null) return null;
|
||||
|
||||
Room room = rooms.get(roomId);
|
||||
if (room == null) return null;
|
||||
|
||||
room.removePlayer(playerId);
|
||||
// Delete room if empty / 如果房间为空则删除
|
||||
if (room.getPlayerCount() == 0) {
|
||||
rooms.remove(roomId);
|
||||
return null;
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
// Get room by player ID / 通过玩家ID获取房间
|
||||
public Room getRoomByPlayerId(String playerId) {
|
||||
String roomId = playerToRoom.get(playerId);
|
||||
if (roomId == null) return null;
|
||||
return rooms.get(roomId);
|
||||
}
|
||||
|
||||
// Get room by room ID / 通过房间ID获取房间
|
||||
public Room getRoom(String roomId) {
|
||||
return rooms.get(roomId);
|
||||
}
|
||||
|
||||
// Get all rooms / 获取所有房间
|
||||
public Collection<Room> getAllRooms() {
|
||||
return rooms.values();
|
||||
}
|
||||
|
||||
// Get rooms that haven't started a game yet / 获取尚未开始游戏的房间
|
||||
public List<Room> getAvailableRooms() {
|
||||
List<Room> available = new ArrayList<>();
|
||||
for (Room room : rooms.values()) {
|
||||
if (!room.isGameStarted()) {
|
||||
available.add(room);
|
||||
}
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
// Remove a room and all player mappings / 移除房间及其所有玩家映射
|
||||
public void removeRoom(String roomId) {
|
||||
Room room = rooms.remove(roomId);
|
||||
if (room != null) {
|
||||
for (PlayerInfo player : room.getPlayers()) {
|
||||
playerToRoom.remove(player.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a player is in any room / 检查玩家是否在任意房间中
|
||||
public boolean isPlayerInRoom(String playerId) {
|
||||
return playerToRoom.containsKey(playerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.zombie.game.systems;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.ecs.System;
|
||||
import com.zombie.game.ecs.components.Position;
|
||||
import com.zombie.game.model.GameMap;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Updates flow field each frame based on player positions / 每帧根据玩家位置更新流场 */
|
||||
public class FlowFieldUpdateSystem implements System {
|
||||
|
||||
@Override
|
||||
public void update(float dt, ECSWorld world) {
|
||||
GameMap map = world.getMap();
|
||||
|
||||
List<float[]> playerPositions = new ArrayList<>();
|
||||
for (int entityId : world.getPlayers()) {
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
if (pos != null) {
|
||||
playerPositions.add(new float[]{pos.getX(), pos.getY()});
|
||||
}
|
||||
}
|
||||
|
||||
if (!playerPositions.isEmpty()) {
|
||||
map.updateFlowField(playerPositions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.zombie.game.systems;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.ecs.System;
|
||||
import com.zombie.game.ecs.components.*;
|
||||
|
||||
import static com.zombie.game.model.Constants.*;
|
||||
|
||||
/**
|
||||
* Processes player input to update positions and aim angles.
|
||||
* 处理玩家输入以更新位置和瞄准角度。
|
||||
*/
|
||||
public class PlayerInputSystem implements System {
|
||||
|
||||
@Override
|
||||
public void update(float dt, ECSWorld world) {
|
||||
// Iterate over all player entities / 遍历所有玩家实体
|
||||
for (int entityId : world.getPlayers()) {
|
||||
// Retrieve components for this entity / 获取该实体的组件
|
||||
PlayerInput input = world.getPlayerInputs().get(entityId);
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
Health health = world.getHealths().get(entityId);
|
||||
|
||||
// Skip if any required component is missing or player is dead / 若缺少必要组件或玩家已死亡则跳过
|
||||
if (input == null || pos == null || health == null || !health.isAlive()) continue;
|
||||
|
||||
// Calculate movement speed based on tick interval / 根据 tick 间隔计算移动速度
|
||||
float speed = PLAYER_SPEED * TICK_INTERVAL;
|
||||
// Compute new position from input direction / 根据输入方向计算新位置
|
||||
float newX = pos.getX() + input.getDx() * speed;
|
||||
float newY = pos.getY() + input.getDy() * speed;
|
||||
|
||||
// Update X position if walkable, clamped to grid bounds / 若可行走则更新 X 位置,限制在网格边界内
|
||||
if (world.getMap().isWalkable(newX, pos.getY(), PLAYER_SIZE)) {
|
||||
pos.setX(Math.max(0.5f, Math.min(GRID_SIZE - 0.5f, newX)));
|
||||
}
|
||||
// Update Y position if walkable, clamped to grid bounds / 若可行走则更新 Y 位置,限制在网格边界内
|
||||
if (world.getMap().isWalkable(pos.getX(), newY, PLAYER_SIZE)) {
|
||||
pos.setY(Math.max(0.5f, Math.min(GRID_SIZE - 0.5f, newY)));
|
||||
}
|
||||
|
||||
// Update facing angle based on aim direction / 根据瞄准方向更新朝向角度
|
||||
pos.setAngle((float) Math.atan2(input.getAimX() - pos.getX(), input.getAimY() - pos.getY()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.zombie.game.systems;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.ecs.System;
|
||||
import com.zombie.game.ecs.components.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Collects and serializes game state for network synchronization.
|
||||
* 收集并序列化游戏状态以进行网络同步。
|
||||
*/
|
||||
public class StateSyncSystem implements System {
|
||||
|
||||
/**
|
||||
* Builds a map representing the current game state including all players and zombies.
|
||||
*/
|
||||
public Map<String, Object> buildGameState(ECSWorld world) {
|
||||
Map<String, Object> state = new LinkedHashMap<>();
|
||||
|
||||
// Players
|
||||
List<Map<String, Object>> playerStates = new ArrayList<>();
|
||||
for (int entityId : world.getPlayers()) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
Health health = world.getHealths().get(entityId);
|
||||
|
||||
String playerId = null;
|
||||
for (var entry : world.getPlayerIdToEntity().entrySet()) {
|
||||
if (entry.getValue() == entityId) {
|
||||
playerId = entry.getKey();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
map.put("id", playerId);
|
||||
if (pos != null) {
|
||||
map.put("x", pos.getX());
|
||||
map.put("y", pos.getY());
|
||||
map.put("angle", pos.getAngle());
|
||||
}
|
||||
if (health != null) {
|
||||
map.put("health", health.getHealth());
|
||||
}
|
||||
|
||||
PlayerInput input = world.getPlayerInputs().get(entityId);
|
||||
if (input != null) {
|
||||
map.put("lastProcessedSeq", input.getSeq());
|
||||
}
|
||||
|
||||
playerStates.add(map);
|
||||
}
|
||||
|
||||
// Zombies (only alive)
|
||||
List<Map<String, Object>> zombieStates = new ArrayList<>();
|
||||
for (int entityId : world.getZombies()) {
|
||||
Health health = world.getHealths().get(entityId);
|
||||
if (health == null || !health.isAlive()) continue;
|
||||
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
|
||||
map.put("id", entityId);
|
||||
if (pos != null) {
|
||||
map.put("x", pos.getX());
|
||||
map.put("y", pos.getY());
|
||||
map.put("angle", pos.getAngle());
|
||||
}
|
||||
map.put("health", health.getHealth());
|
||||
map.put("maxHealth", health.getMaxHealth());
|
||||
|
||||
zombieStates.add(map);
|
||||
}
|
||||
|
||||
// Nut walls
|
||||
List<Map<String, Object>> nutWallStates = new ArrayList<>();
|
||||
for (Map.Entry<Integer, Health> entry : world.getHealths().entrySet()) {
|
||||
int entityId = entry.getKey();
|
||||
// Skip non-wall entities (players and zombies have health too)
|
||||
if (world.getPlayers().contains(entityId) || world.getZombies().contains(entityId)) continue;
|
||||
if (!world.getBlocksMovements().containsKey(entityId)) continue;
|
||||
|
||||
Health health = entry.getValue();
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
if (pos == null) continue;
|
||||
|
||||
Map<String, Object> wallState = new LinkedHashMap<>();
|
||||
wallState.put("id", entityId);
|
||||
wallState.put("x", (int) Math.floor(pos.getX()));
|
||||
wallState.put("y", (int) Math.floor(pos.getY()));
|
||||
wallState.put("health", health.getHealth());
|
||||
wallState.put("maxHealth", health.getMaxHealth());
|
||||
nutWallStates.add(wallState);
|
||||
}
|
||||
|
||||
state.put("players", playerStates);
|
||||
state.put("zombies", zombieStates);
|
||||
state.put("nutWalls", nutWallStates);
|
||||
state.put("gameTime", world.getGameTime());
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(float dt, ECSWorld world) {
|
||||
// No per-frame logic; state is built on demand
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.zombie.game.systems;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.ecs.System;
|
||||
import com.zombie.game.ecs.components.*;
|
||||
import com.zombie.game.model.GameMap;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static com.zombie.game.model.Constants.*;
|
||||
|
||||
/** Zombie movement system using flow field pathfinding / 僵尸移动系统,使用流场寻路 */
|
||||
public class ZombieMovementSystem implements System {
|
||||
|
||||
@Override
|
||||
public void update(float dt, ECSWorld world) {
|
||||
GameMap map = world.getMap();
|
||||
if (!map.isFlowFieldValid()) return;
|
||||
|
||||
List<Integer> sortedZombies = new ArrayList<>(world.getZombies());
|
||||
Collections.sort(sortedZombies);
|
||||
|
||||
for (int entityId : sortedZombies) {
|
||||
Position pos = world.getPositions().get(entityId);
|
||||
Health health = world.getHealths().get(entityId);
|
||||
ZombieAI ai = world.getZombieAIs().get(entityId);
|
||||
|
||||
if (pos == null || health == null || !health.isAlive()) continue;
|
||||
if (ai == null) continue;
|
||||
|
||||
// Target selection: query flow field for next grid
|
||||
float centerDist = Float.MAX_VALUE;
|
||||
if (ai.isHasTarget()) {
|
||||
float dx = ai.getTargetX() - pos.getX();
|
||||
float dy = ai.getTargetY() - pos.getY();
|
||||
centerDist = (float) Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
if (!ai.isHasTarget() || centerDist < GRID_CENTER_THRESHOLD) {
|
||||
float[] flowDir = map.getFlowDirection(pos.getX(), pos.getY());
|
||||
float dirX = flowDir[0];
|
||||
float dirY = flowDir[1];
|
||||
|
||||
if (dirX == 0 && dirY == 0) continue;
|
||||
|
||||
int currentGridX = (int) Math.floor(pos.getX());
|
||||
int currentGridY = (int) Math.floor(pos.getY());
|
||||
int nextGridX = currentGridX + (int) Math.round(dirX);
|
||||
int nextGridY = currentGridY + (int) Math.round(dirY);
|
||||
|
||||
// If next grid is wall, try to slide along single axis
|
||||
if (map.isWall(nextGridX, nextGridY)) {
|
||||
nextGridX = currentGridX + (int) Math.signum(dirX);
|
||||
nextGridY = currentGridY + (int) Math.signum(dirY);
|
||||
if (map.isWall(nextGridX, nextGridY)) {
|
||||
if (!map.isWall(currentGridX + (int) Math.signum(dirX), currentGridY)) {
|
||||
nextGridX = currentGridX + (int) Math.signum(dirX);
|
||||
nextGridY = currentGridY;
|
||||
} else if (!map.isWall(currentGridX, currentGridY + (int) Math.signum(dirY))) {
|
||||
nextGridX = currentGridX;
|
||||
nextGridY = currentGridY + (int) Math.signum(dirY);
|
||||
} else {
|
||||
ai.setReservation(false);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check grid occupation by other zombies
|
||||
if (isGridOccupiedOrReserved(nextGridX, nextGridY, world, entityId)) {
|
||||
int[] alt = findAlternativeDirection(currentGridX, currentGridY, dirX, dirY, map, world, entityId);
|
||||
if (alt != null) {
|
||||
nextGridX = alt[0];
|
||||
nextGridY = alt[1];
|
||||
} else {
|
||||
ai.setReservation(false);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
ai.setReservedGridX(nextGridX);
|
||||
ai.setReservedGridY(nextGridY);
|
||||
ai.setReservation(true);
|
||||
ai.setTargetX(nextGridX + 0.5f);
|
||||
ai.setTargetY(nextGridY + 0.5f);
|
||||
ai.setHasTarget(true);
|
||||
}
|
||||
|
||||
// Move toward target
|
||||
float dx = ai.getTargetX() - pos.getX();
|
||||
float dy = ai.getTargetY() - pos.getY();
|
||||
float dist = (float) Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 0.01f) {
|
||||
ai.setHasTarget(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
float moveDirX = dx / dist;
|
||||
float moveDirY = dy / dist;
|
||||
float speed = ZOMBIE_SPEED;
|
||||
|
||||
float moveX = moveDirX * speed * dt;
|
||||
float moveY = moveDirY * speed * dt;
|
||||
float newX = pos.getX() + moveX;
|
||||
float newY = pos.getY() + moveY;
|
||||
|
||||
boolean canMoveX = map.isWalkable(newX, pos.getY(), ZOMBIE_SIZE);
|
||||
boolean canMoveY = map.isWalkable(pos.getX(), newY, ZOMBIE_SIZE);
|
||||
|
||||
if (moveX != 0 && moveY != 0) {
|
||||
// Diagonal: check corner blocking
|
||||
int checkX = (int) Math.floor(newX);
|
||||
int checkY = (int) Math.floor(newY);
|
||||
int checkCurrentX = (int) Math.floor(pos.getX());
|
||||
int checkCurrentY = (int) Math.floor(pos.getY());
|
||||
|
||||
boolean blockedByCorner = false;
|
||||
if (checkX != checkCurrentX && checkY != checkCurrentY) {
|
||||
boolean wallInX = map.isWall(checkX, checkCurrentY);
|
||||
boolean wallInY = map.isWall(checkCurrentX, checkY);
|
||||
if (wallInX || wallInY) {
|
||||
blockedByCorner = true;
|
||||
if (!wallInX && canMoveX) {
|
||||
pos.setX(newX);
|
||||
} else if (!wallInY && canMoveY) {
|
||||
pos.setY(newY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!blockedByCorner) {
|
||||
boolean canMoveDiag = map.isWalkable(newX, newY, ZOMBIE_SIZE);
|
||||
if (canMoveDiag) {
|
||||
pos.setX(newX);
|
||||
pos.setY(newY);
|
||||
} else {
|
||||
if (canMoveX) pos.setX(newX);
|
||||
if (canMoveY) pos.setY(newY);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (canMoveX) pos.setX(newX);
|
||||
if (canMoveY) pos.setY(newY);
|
||||
}
|
||||
|
||||
// Zombie separation
|
||||
for (int otherId : world.getZombies()) {
|
||||
if (otherId == entityId) continue;
|
||||
Health otherHealth = world.getHealths().get(otherId);
|
||||
if (otherHealth == null || !otherHealth.isAlive()) continue;
|
||||
Position otherPos = world.getPositions().get(otherId);
|
||||
if (otherPos == null) continue;
|
||||
|
||||
float sepDx = pos.getX() - otherPos.getX();
|
||||
float sepDy = pos.getY() - otherPos.getY();
|
||||
float sepDist = (float) Math.sqrt(sepDx * sepDx + sepDy * sepDy);
|
||||
|
||||
if (sepDist < ZOMBIE_SIZE && sepDist > 0.01f) {
|
||||
float overlap = ZOMBIE_SIZE - sepDist;
|
||||
float pushX = (sepDx / sepDist) * overlap * 0.5f;
|
||||
float pushY = (sepDy / sepDist) * overlap * 0.5f;
|
||||
|
||||
if (map.isWalkable(pos.getX() + pushX, pos.getY() + pushY, ZOMBIE_SIZE)) {
|
||||
pos.setX(pos.getX() + pushX);
|
||||
pos.setY(pos.getY() + pushY);
|
||||
} else {
|
||||
if (map.isWalkable(pos.getX() + pushX, pos.getY(), ZOMBIE_SIZE)) {
|
||||
pos.setX(pos.getX() + pushX);
|
||||
}
|
||||
if (map.isWalkable(pos.getX(), pos.getY() + pushY, ZOMBIE_SIZE)) {
|
||||
pos.setY(pos.getY() + pushY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update facing angle
|
||||
if (moveDirX != 0 || moveDirY != 0) {
|
||||
pos.setAngle((float) Math.atan2(moveDirX, moveDirY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isGridOccupiedOrReserved(int gridX, int gridY, ECSWorld world, int selfId) {
|
||||
for (int otherId : world.getZombies()) {
|
||||
if (otherId == selfId) continue;
|
||||
Health otherHealth = world.getHealths().get(otherId);
|
||||
if (otherHealth == null || !otherHealth.isAlive()) continue;
|
||||
|
||||
Position otherPos = world.getPositions().get(otherId);
|
||||
if (otherPos == null) continue;
|
||||
|
||||
int otherGridX = (int) Math.floor(otherPos.getX());
|
||||
int otherGridY = (int) Math.floor(otherPos.getY());
|
||||
if (otherGridX == gridX && otherGridY == gridY) return true;
|
||||
|
||||
ZombieAI otherAi = world.getZombieAIs().get(otherId);
|
||||
if (otherAi != null && otherAi.isReservation() &&
|
||||
otherAi.getReservedGridX() == gridX && otherAi.getReservedGridY() == gridY) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private int[] findAlternativeDirection(int currentGridX, int currentGridY, float dirX, float dirY,
|
||||
GameMap map, ECSWorld world, int selfId) {
|
||||
int[][] allDirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}};
|
||||
List<int[]> candidates = new ArrayList<>();
|
||||
|
||||
for (int[] dir : allDirs) {
|
||||
int nx = currentGridX + dir[0];
|
||||
int ny = currentGridY + dir[1];
|
||||
|
||||
if (map.isWall(nx, ny)) continue;
|
||||
|
||||
if (dir[0] != 0 && dir[1] != 0) {
|
||||
if (map.isWall(currentGridX + dir[0], currentGridY) ||
|
||||
map.isWall(currentGridX, currentGridY + dir[1])) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isGridOccupiedOrReserved(nx, ny, world, selfId)) continue;
|
||||
|
||||
float dotProduct = dir[0] * dirX + dir[1] * dirY;
|
||||
candidates.add(new int[]{nx, ny, (int) (dotProduct * 1000)});
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
candidates.sort((a, b) -> b[2] - a[2]);
|
||||
return new int[]{candidates.get(0)[0], candidates.get(0)[1]};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.zombie.game.systems;
|
||||
|
||||
import com.zombie.game.ecs.ECSWorld;
|
||||
import com.zombie.game.ecs.System;
|
||||
import com.zombie.game.ecs.components.Health;
|
||||
import com.zombie.game.model.GameMap;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import static com.zombie.game.model.Constants.*;
|
||||
|
||||
/** Periodically spawns zombies at map spawn points / 定期在地图出生点生成僵尸 */
|
||||
public class ZombieSpawnSystem implements System {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ZombieSpawnSystem.class);
|
||||
private final Random random = new Random();
|
||||
private float spawnTimer = 0;
|
||||
|
||||
@Override
|
||||
public void update(float dt, ECSWorld world) {
|
||||
spawnTimer += dt;
|
||||
if (spawnTimer < ZOMBIE_SPAWN_INTERVAL) return;
|
||||
spawnTimer -= ZOMBIE_SPAWN_INTERVAL;
|
||||
|
||||
// Count alive zombies
|
||||
int aliveCount = 0;
|
||||
for (int entityId : world.getZombies()) {
|
||||
Health health = world.getHealths().get(entityId);
|
||||
if (health != null && health.isAlive()) {
|
||||
aliveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (aliveCount >= ZOMBIE_MAX_COUNT) return;
|
||||
|
||||
// Pick a random spawn point
|
||||
GameMap map = world.getMap();
|
||||
List<int[]> spawns = map.getZombieSpawnPoints();
|
||||
if (spawns.isEmpty()) return;
|
||||
|
||||
int[] sp = spawns.get(random.nextInt(spawns.size()));
|
||||
int newZombie = world.createZombieEntity(sp[0] + 0.5f, sp[1] + 0.5f);
|
||||
logger.info("Zombie spawned: id={} at ({}, {}), alive={}", newZombie, sp[0], sp[1], aliveCount + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user