This commit is contained in:
wfz
2026-04-26 11:00:59 +08:00
parent 7bffe41d41
commit f1a6f0fd75
19 changed files with 1026 additions and 142 deletions

View File

@@ -32,6 +32,11 @@
<artifactId>gson</artifactId> <artifactId>gson</artifactId>
<version>2.10.1</version> <version>2.10.1</version>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId> <artifactId>slf4j-simple</artifactId>

View File

@@ -1,16 +1,21 @@
package com.zombie.game; package com.zombie.game;
import com.zombie.game.server.GameWebSocketServer; import com.zombie.game.server.GameWebSocketServer;
import com.zombie.game.server.MapDesignerApiServer;
import com.zombie.game.server.MapStorage;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.IOException;
/** /**
* 游戏服务器主入口类 * 游戏服务器主入口类
* *
* 职责: * 职责:
* 1. 解析命令行参数(端口号) * 1. 解析命令行参数(端口号)
* 2. 启动 WebSocket 游戏服务器 * 2. 启动 WebSocket 游戏服务器
* 3. 注册 JVM 关闭钩子,确保服务器优雅退出 * 3. 启动地图设计器 API 服务器
* 4. 注册 JVM 关闭钩子,确保服务器优雅退出
*/ */
public class GameServerMain { public class GameServerMain {
private static final Logger logger = LoggerFactory.getLogger(GameServerMain.class); private static final Logger logger = LoggerFactory.getLogger(GameServerMain.class);
@@ -20,7 +25,7 @@ public class GameServerMain {
* *
* @param args 命令行参数,第一个参数可选为端口号(默认 8080 * @param args 命令行参数,第一个参数可选为端口号(默认 8080
*/ */
public static void main(String[] args) { public static void main(String[] args) throws IOException {
// 默认监听端口 // 默认监听端口
int port = 8080; int port = 8080;
@@ -37,13 +42,20 @@ public class GameServerMain {
GameWebSocketServer server = new GameWebSocketServer(port); GameWebSocketServer server = new GameWebSocketServer(port);
server.start(); server.start();
// 创建并启动地图设计器 API 服务器
MapStorage mapStorage = new MapStorage();
MapDesignerApiServer apiServer = new MapDesignerApiServer(mapStorage);
apiServer.start();
logger.info("Zombie Crisis 3 Server started on port {}", port); logger.info("Zombie Crisis 3 Server started on port {}", port);
logger.info("Map Designer API Server started on port 8081");
logger.info("Press Ctrl+C to stop the server"); logger.info("Press Ctrl+C to stop the server");
// 注册关闭钩子:当 JVM 退出时Ctrl+C 或系统信号),优雅地停止服务器 // 注册关闭钩子:当 JVM 退出时Ctrl+C 或系统信号),优雅地停止服务器
Runtime.getRuntime().addShutdownHook(new Thread(() -> { Runtime.getRuntime().addShutdownHook(new Thread(() -> {
logger.info("Shutting down server..."); logger.info("Shutting down server...");
try { try {
apiServer.stop();
server.stop(); server.stop();
} catch (Exception e) { } catch (Exception e) {
logger.error("Error during server shutdown", e); logger.error("Error during server shutdown", e);

View File

@@ -3,6 +3,8 @@ package com.zombie.game.model;
import lombok.Getter; import lombok.Getter;
import java.util.*; import java.util.*;
import static com.zombie.game.model.Constants.*;
/** /**
* 子弹/投掷物类 * 子弹/投掷物类
* *
@@ -123,7 +125,9 @@ public class Bullet {
int gx = (int) Math.floor(x); int gx = (int) Math.floor(x);
int gy = (int) Math.floor(y); int gy = (int) Math.floor(y);
if (map.isWall(gx, gy)) return false; // 只有碰到静态墙壁才销毁子弹,碰到坚果墙体让碰撞检测处理
Wall wall = map.getWall(gx, gy);
if (wall instanceof StaticWall) return false;
return true; return true;
} }

View File

@@ -47,7 +47,7 @@ public class Constants {
/** 僵尸生成间隔基础值(秒),难度提升后会逐渐缩短 */ /** 僵尸生成间隔基础值(秒),难度提升后会逐渐缩短 */
public static final float ZOMBIE_SPAWN_INTERVAL_BASE = 3.0f; public static final float ZOMBIE_SPAWN_INTERVAL_BASE = 3.0f;
/** 僵尸生成间隔最小值(秒),防止生成过快 */ /** 僵尸生成间隔最小值(秒),防止生成过快 */
public static final float ZOMBIE_SPAWN_INTERVAL_MIN = 0.5f; public static final float ZOMBIE_SPAWN_INTERVAL_MIN = 0.2f;
/** 每次难度提升增加的僵尸生命值 */ /** 每次难度提升增加的僵尸生命值 */
public static final float ZOMBIE_HEALTH_INCREASE = 20; public static final float ZOMBIE_HEALTH_INCREASE = 20;
/** 每次难度提升增加的僵尸速度 */ /** 每次难度提升增加的僵尸速度 */

View File

@@ -1,6 +1,9 @@
package com.zombie.game.model; package com.zombie.game.model;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter; import lombok.Getter;
import java.io.File;
import java.util.*; import java.util.*;
/** /**
@@ -23,99 +26,97 @@ public class GameMap {
private final Map<String, Wall> walls; private final Map<String, Wall> walls;
/** 流场导航 */ /** 流场导航 */
private final FlowField flowField; private final FlowField flowField;
/** 玩家出生点 */
private final List<int[]> spawnPoints;
/** 僵尸出生点 */
private final List<int[]> zombieSpawnPoints;
/** /**
* 构造函数 - 初始化地图并生成默认布局 * 构造函数 - 从 JSON 文件加载地图
*
* @param mapFilePath 地图 JSON 文件路径
*/ */
public GameMap() { public GameMap(String mapFilePath) {
this.width = Constants.GRID_SIZE; JsonNode root = loadMapFile(mapFilePath);
this.height = Constants.GRID_SIZE; this.width = root.get("width").asInt();
this.height = root.get("height").asInt();
this.cells = new int[height][width]; this.cells = new int[height][width];
this.walls = new HashMap<>(); this.walls = new HashMap<>();
this.flowField = new FlowField(width, height); this.flowField = new FlowField(width, height);
generateDefaultMap(); this.spawnPoints = new ArrayList<>();
this.zombieSpawnPoints = new ArrayList<>();
loadFromJson(root);
} }
/** /**
* 生成默认地图布局 * 加载地图 JSON 文件
*
* 创建边界墙壁、内部障碍物、玩家出生点和僵尸出生点
*/ */
private void generateDefaultMap() { private JsonNode loadMapFile(String mapFilePath) {
for (int y = 0; y < height; y++) { try {
for (int x = 0; x < width; x++) { ObjectMapper mapper = new ObjectMapper();
if (x == 0 || x == width - 1 || y == 0 || y == height - 1) { return mapper.readTree(new File(mapFilePath));
cells[y][x] = 0; } catch (Exception e) {
throw new RuntimeException("Failed to load map file: " + mapFilePath, e);
}
}
/**
* 从 JSON 数据加载地图
*/
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)) {
walls.put(key(x, y), new StaticWall(x, y)); walls.put(key(x, y), new StaticWall(x, y));
} else { } else if ("nut".equals(type)) {
cells[y][x] = 0; walls.put(key(x, y), new NutWall(x, y));
} }
} }
} }
int[][] wallSegments = { // 加载玩家出生点
{5, 5, 5, 10}, {10, 3, 15, 3}, {20, 5, 20, 12}, JsonNode playerSpawns = root.get("playerSpawns");
{8, 15, 14, 15}, {25, 10, 25, 18}, {3, 20, 8, 20}, if (playerSpawns != null && playerSpawns.isArray()) {
{15, 20, 15, 26}, {22, 22, 28, 22}, {5, 25, 10, 25}, for (JsonNode spawn : playerSpawns) {
{12, 8, 12, 12}, {18, 16, 22, 16}, {27, 5, 27, 9}, int x = spawn.get("x").asInt();
{8, 27, 13, 27}, {18, 26, 18, 30} int y = spawn.get("y").asInt();
}; if (x >= 0 && x < width && y >= 0 && y < height) {
cells[y][x] = 2;
for (int[] seg : wallSegments) { spawnPoints.add(new int[]{x, y});
if (seg[0] == seg[2]) {
for (int y = seg[1]; y <= seg[3]; y++) {
if (seg[0] > 0 && seg[0] < width - 1 && y > 0 && y < height - 1) {
walls.put(key(seg[0], y), new StaticWall(seg[0], y));
}
}
} else {
for (int x = seg[0]; x <= seg[2]; x++) {
if (x > 0 && x < width - 1 && seg[1] > 0 && seg[1] < height - 1) {
walls.put(key(x, seg[1]), new StaticWall(x, seg[1]));
}
} }
} }
} }
int[][] spawnPoints = {{2, 2}, {29, 2}, {2, 29}, {29, 29}}; // 加载僵尸出生点
for (int[] sp : spawnPoints) { JsonNode zombieSpawns = root.get("zombieSpawns");
cells[sp[1]][sp[0]] = 2; if (zombieSpawns != null && zombieSpawns.isArray()) {
for (int dy = -1; dy <= 1; dy++) { for (JsonNode spawn : zombieSpawns) {
for (int dx = -1; dx <= 1; dx++) { int x = spawn.get("x").asInt();
int ny = sp[1] + dy, nx = sp[0] + dx; int y = spawn.get("y").asInt();
if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1) { if (x >= 0 && x < width && y >= 0 && y < height) {
walls.remove(key(nx, ny)); cells[y][x] = 3;
} zombieSpawnPoints.add(new int[]{x, y});
}
}
}
int[][] zombieSpawnAreas = {{16, 2}, {2, 16}, {29, 16}, {16, 29}, {16, 16}};
for (int[] sp : zombieSpawnAreas) {
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
int ny = sp[1] + dy, nx = sp[0] + dx;
if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1) {
walls.remove(key(nx, ny));
}
}
}
}
int[][] zombieSpawnPoints = {{8, 8}, {24, 24}};
for (int[] sp : zombieSpawnPoints) {
cells[sp[1]][sp[0]] = 3;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
int ny = sp[1] + dy, nx = sp[0] + dx;
if (ny > 0 && ny < height - 1 && nx > 0 && nx < width - 1) {
walls.remove(key(nx, ny));
}
} }
} }
} }
} }
/**
* 添加坚果墙体
*
* @param gx 格子X坐标
* @param gy 格子Y坐标
* @return true 表示添加成功
*/
/** /**
* 添加坚果墙体 * 添加坚果墙体
* *
@@ -201,8 +202,53 @@ public class GameMap {
return true; return true;
} }
/**
* 获取地图数据(包含墙体信息)
*
* 返回的二维数组格式与前端兼容:
* - 0 = 空地
* - 1 = 静态墙壁
* - 2 = 玩家出生点
* - 3 = 僵尸出生点
* - 4 = 坚果墙体
*
* @return 地图格子数据
*/
public int[][] getCells() { public int[][] getCells() {
return cells; int[][] result = new int[height][width];
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
Wall wall = walls.get(key(x, y));
if (wall instanceof StaticWall) {
result[y][x] = 1;
} else if (wall instanceof NutWall) {
result[y][x] = 4;
} else {
result[y][x] = cells[y][x];
}
}
}
return result;
}
/**
* 获取所有坚果墙体的状态(用于同步给前端)
*
* @return 坚果墙体状态列表,每个元素包含 x, y, health, maxHealth
*/
public List<Map<String, Object>> getNutWallStates() {
List<Map<String, Object>> states = new ArrayList<>();
for (Wall wall : walls.values()) {
if (wall instanceof NutWall && !wall.isDestroyed()) {
Map<String, Object> state = new LinkedHashMap<>();
state.put("x", wall.getGridX());
state.put("y", wall.getGridY());
state.put("health", wall.getHealth());
state.put("maxHealth", NutWall.MAX_HEALTH);
states.add(state);
}
}
return states;
} }
/** /**
@@ -228,12 +274,7 @@ public class GameMap {
* @return 僵尸出生点坐标列表 * @return 僵尸出生点坐标列表
*/ */
public List<int[]> getZombieSpawnPoints() { public List<int[]> getZombieSpawnPoints() {
List<int[]> points = new ArrayList<>(); return zombieSpawnPoints;
int[][] zombieSpawns = {{8, 8}, {24, 24}};
for (int[] sp : zombieSpawns) {
points.add(sp);
}
return points;
} }
/** /**

View File

@@ -3,6 +3,8 @@ package com.zombie.game.model;
import lombok.Getter; import lombok.Getter;
import java.util.*; import java.util.*;
import static com.zombie.game.model.Constants.*;
/** /**
* 游戏世界类 * 游戏世界类
* *
@@ -39,7 +41,11 @@ public class GameWorld {
private List<Integer> removedZombieBullets; private List<Integer> removedZombieBullets;
public GameWorld() { public GameWorld() {
this.map = new GameMap(); this("/Users/wfz/workspace/zp1/maps/d540209a.json");
}
public GameWorld(String mapFilePath) {
this.map = new GameMap(mapFilePath);
this.players = new LinkedHashMap<>(); this.players = new LinkedHashMap<>();
this.zombies = new LinkedHashMap<>(); this.zombies = new LinkedHashMap<>();
this.bullets = new LinkedHashMap<>(); this.bullets = new LinkedHashMap<>();
@@ -206,17 +212,17 @@ public class GameWorld {
/** /**
* 更新所有僵尸 * 更新所有僵尸
* *
* 处理僵尸移动、攻击和死亡 * 处理僵尸移动、攻击和死亡
* *
* @param dt 时间增量(秒) * @param dt 时间增量(秒)
*/ */
private void updateZombies(float dt) { private void updateZombies(float dt) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
List<Zombie> sortedZombies = new ArrayList<>(zombies.values()); List<Zombie> sortedZombies = new ArrayList<>(zombies.values());
sortedZombies.sort((a, b) -> Integer.compare(a.getId(), b.getId())); sortedZombies.sort((a, b) -> Integer.compare(a.getId(), b.getId()));
for (Zombie z : sortedZombies) { for (Zombie z : sortedZombies) {
if (!z.isAlive()) { if (!z.isAlive()) {
onZombieKilled(z); onZombieKilled(z);
@@ -235,7 +241,14 @@ public class GameWorld {
} }
} }
z.move(map, dt, zombies.values()); Wall attackedWall = z.move(map, dt, zombies.values(), now);
if (attackedWall != null && z.canAttack(now)) {
attackedWall.takeDamage(1.0f); // 每次攻击造成1点伤害
z.attack(now);
if (attackedWall.isDestroyed()) {
map.removeWall(attackedWall.getGridX(), attackedWall.getGridY());
}
}
} }
} }
@@ -278,19 +291,40 @@ public class GameWorld {
} }
/** /**
* 检测僵尸子弹与玩家的碰撞 * 检测僵尸子弹与玩家/墙体的碰撞
*/ */
private void checkZombieBulletCollisions() { private void checkZombieBulletCollisions() {
List<Integer> bulletsToRemove = new ArrayList<>(); List<Integer> bulletsToRemove = new ArrayList<>();
for (Bullet b : new ArrayList<>(zombieBullets.values())) { for (Bullet b : new ArrayList<>(zombieBullets.values())) {
boolean hit = false;
// 检测是否命中玩家
for (Player p : new ArrayList<>(players.values())) { for (Player p : new ArrayList<>(players.values())) {
if (!p.isAlive()) continue; if (!p.isAlive()) continue;
if (b.hitsEntity(p.getX(), p.getY(), Constants.PLAYER_SIZE)) { if (b.hitsEntity(p.getX(), p.getY(), Constants.PLAYER_SIZE)) {
p.takeDamage(b.getDamage()); p.takeDamage(b.getDamage());
bulletsToRemove.add(b.getId()); hit = true;
break; break;
} }
} }
// 检测是否命中坚果墙体
if (!hit) {
int gx = (int) Math.floor(b.getX());
int gy = (int) Math.floor(b.getY());
Wall wall = map.getWall(gx, gy);
if (wall instanceof NutWall && !wall.isDestroyed()) {
wall.takeDamage(b.getDamage());
hit = true;
if (wall.isDestroyed()) {
map.removeWall(gx, gy);
}
}
}
if (hit) {
bulletsToRemove.add(b.getId());
}
} }
for (int id : bulletsToRemove) { for (int id : bulletsToRemove) {
zombieBullets.remove(id); zombieBullets.remove(id);
@@ -352,20 +386,41 @@ public class GameWorld {
} }
/** /**
* 检测玩家子弹与僵尸的碰撞 * 检测玩家子弹与僵尸/墙体的碰撞
*/ */
private void checkBulletCollisions() { private void checkBulletCollisions() {
List<Integer> bulletsToRemove = new ArrayList<>(); List<Integer> bulletsToRemove = new ArrayList<>();
for (Bullet b : new ArrayList<>(bullets.values())) { for (Bullet b : new ArrayList<>(bullets.values())) {
if (b.isGrenade()) continue; if (b.isGrenade()) continue;
// 检测是否命中僵尸
boolean hit = false;
for (Zombie z : new ArrayList<>(zombies.values())) { for (Zombie z : new ArrayList<>(zombies.values())) {
if (!z.isAlive()) continue; if (!z.isAlive()) continue;
if (b.hitsEntity(z.getX(), z.getY(), Constants.ZOMBIE_SIZE)) { if (b.hitsEntity(z.getX(), z.getY(), Constants.ZOMBIE_SIZE)) {
z.takeDamage(b.getDamage()); z.takeDamage(b.getDamage());
bulletsToRemove.add(b.getId()); hit = true;
break; break;
} }
} }
// 检测是否命中坚果墙体
if (!hit) {
int gx = (int) Math.floor(b.getX());
int gy = (int) Math.floor(b.getY());
Wall wall = map.getWall(gx, gy);
if (wall instanceof NutWall && !wall.isDestroyed()) {
wall.takeDamage(b.getDamage());
hit = true;
if (wall.isDestroyed()) {
map.removeWall(gx, gy);
}
}
}
if (hit) {
bulletsToRemove.add(b.getId());
}
} }
for (int id : bulletsToRemove) { for (int id : bulletsToRemove) {
bullets.remove(id); bullets.remove(id);
@@ -615,6 +670,7 @@ public class GameWorld {
state.put("explosions", new ArrayList<>(explosions)); state.put("explosions", new ArrayList<>(explosions));
state.put("removedBullets", new ArrayList<>(removedBullets)); state.put("removedBullets", new ArrayList<>(removedBullets));
state.put("nutWalls", map.getNutWallStates());
state.put("gameTime", gameTime); state.put("gameTime", gameTime);
state.put("waveNumber", waveNumber); state.put("waveNumber", waveNumber);
state.put("score", score); state.put("score", score);

View File

@@ -3,6 +3,8 @@ package com.zombie.game.model;
import lombok.Getter; import lombok.Getter;
import java.util.*; import java.util.*;
import static com.zombie.game.model.Constants.*;
/** /**
* 掉落物类 * 掉落物类
* *

View File

@@ -0,0 +1,120 @@
package com.zombie.game.model;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.ArrayList;
/**
* 地图数据类
*
* 用于JSON序列化和反序列化
*/
public class MapData {
private String id;
private String name;
private int width;
private int height;
private List<Map<String, Object>> walls;
private List<Map<String, Integer>> playerSpawns;
private List<Map<String, Integer>> zombieSpawns;
public MapData() {
this.walls = new ArrayList<>();
this.playerSpawns = new ArrayList<>();
this.zombieSpawns = new ArrayList<>();
}
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;
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getWidth() { return width; }
public void setWidth(int width) { this.width = width; }
public int getHeight() { return height; }
public void setHeight(int height) { this.height = height; }
public List<Map<String, Object>> getWalls() { return walls; }
public void setWalls(List<Map<String, Object>> walls) { this.walls = walls; }
public List<Map<String, Integer>> getPlayerSpawns() { return playerSpawns; }
public void setPlayerSpawns(List<Map<String, Integer>> playerSpawns) { this.playerSpawns = playerSpawns; }
public List<Map<String, Integer>> getZombieSpawns() { return zombieSpawns; }
public void setZombieSpawns(List<Map<String, Integer>> zombieSpawns) { this.zombieSpawns = zombieSpawns; }
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;
}
}
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] = "nut".equals(wall.get("type")) ? 2 : 1;
}
}
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;
}
}
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;
}
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<>();
for (Map.Entry<String, Wall> entry : map.getWalls().entrySet()) {
String[] parts = entry.getKey().split(",");
int x = Integer.parseInt(parts[0]);
int y = Integer.parseInt(parts[1]);
Wall wall = entry.getValue();
Map<String, Object> wallData = new HashMap<>();
wallData.put("x", x);
wallData.put("y", y);
wallData.put("type", wall instanceof NutWall ? "nut" : "static");
walls.add(wallData);
}
for (int[] spawn : map.getSpawnPoints()) {
Map<String, Integer> sp = new HashMap<>();
sp.put("x", spawn[0]);
sp.put("y", spawn[1]);
playerSpawns.add(sp);
}
for (int[] spawn : map.getZombieSpawnPoints()) {
Map<String, Integer> sp = new HashMap<>();
sp.put("x", spawn[0]);
sp.put("y", spawn[1]);
zombieSpawns.add(sp);
}
return new MapData(id, name, map.getWidth(), map.getHeight(), walls, playerSpawns, zombieSpawns);
}
}

View File

@@ -3,6 +3,8 @@ package com.zombie.game.model;
import lombok.Getter; import lombok.Getter;
import java.util.*; import java.util.*;
import static com.zombie.game.model.Constants.*;
/** /**
* 玩家类 * 玩家类
* *

View File

@@ -3,6 +3,8 @@ package com.zombie.game.model;
import lombok.Getter; import lombok.Getter;
import java.util.*; import java.util.*;
import static com.zombie.game.model.Constants.*;
/** /**
* 游戏房间类 * 游戏房间类
* *

View File

@@ -3,6 +3,8 @@ package com.zombie.game.model;
import lombok.Getter; import lombok.Getter;
import java.util.*; import java.util.*;
import static com.zombie.game.model.Constants.*;
/** /**
* 僵尸类 * 僵尸类
* *
@@ -80,49 +82,84 @@ public class Zombie {
/** /**
* 移动僵尸 * 移动僵尸
* *
* 基于流场导航移动,包含: * 基于流场导航移动,包含:
* - 路径规划 * - 路径规划(支持加权障碍物,自动权衡绕道 vs 摧毁)
* - 避免与其他僵尸重叠 * - 避免与其他僵尸重叠
* - 墙壁碰撞检测 * - 墙壁碰撞检测
* * - 攻击坚果墙体逻辑
*
* @param map 游戏地图 * @param map 游戏地图
* @param dt 时间增量(秒) * @param dt 时间增量(秒)
* @param otherZombies 其他僵尸集合 * @param otherZombies 其他僵尸集合
* @param now 当前时间戳
* @return 如果正在攻击墙体,返回被攻击的墙体对象;否则返回 null
*/ */
public void move(GameMap map, float dt, Collection<Zombie> otherZombies) { public Wall move(GameMap map, float dt, Collection<Zombie> otherZombies, long now) {
if (!map.isFlowFieldValid()) return; if (!map.isFlowFieldValid()) return null;
int currentGridX = (int) Math.floor(x); int currentGridX = (int) Math.floor(x);
int currentGridY = (int) Math.floor(y); int currentGridY = (int) Math.floor(y);
// 如果正在攻击墙体,检查是否到达攻击位置
if (attackingWall && attackingWallGridX >= 0) {
float wallCenterX = attackingWallGridX + 0.5f;
float wallCenterY = attackingWallGridY + 0.5f;
float distToWall = distanceTo(wallCenterX, wallCenterY);
if (distToWall < 0.8f) {
// 到达攻击位置,返回要攻击的墙体
Wall wall = map.getWall(attackingWallGridX, attackingWallGridY);
if (wall != null && !wall.isDestroyed()) {
return wall;
}
// 墙体已被破坏或被移除,停止攻击
attackingWall = false;
attackingWallGridX = -1;
attackingWallGridY = -1;
}
}
float centerDist = Float.MAX_VALUE; float centerDist = Float.MAX_VALUE;
if (hasTarget) { if (hasTarget) {
float dx = targetX - x; float dx = targetX - x;
float dy = targetY - y; float dy = targetY - y;
centerDist = (float) Math.sqrt(dx * dx + dy * dy); centerDist = (float) Math.sqrt(dx * dx + dy * dy);
} }
if (!hasTarget || centerDist < 0.15f) { if (!hasTarget || centerDist < 0.15f) {
float[] flowDir = map.getFlowDirection(x, y); float[] flowDir = map.getFlowDirection(x, y);
float dirX = flowDir[0]; float dirX = flowDir[0];
float dirY = flowDir[1]; float dirY = flowDir[1];
if (dirX == 0 && dirY == 0) return; if (dirX == 0 && dirY == 0) return null;
float len = (float) Math.sqrt(dirX * dirX + dirY * dirY); float len = (float) Math.sqrt(dirX * dirX + dirY * dirY);
if (len > 0) { if (len > 0) {
dirX /= len; dirX /= len;
dirY /= len; dirY /= len;
} }
int nextGridX = currentGridX + (int) Math.round(dirX); int nextGridX = currentGridX + (int) Math.round(dirX);
int nextGridY = currentGridY + (int) Math.round(dirY); int nextGridY = currentGridY + (int) Math.round(dirY);
if (map.isWall(nextGridX, nextGridY)) { // 检查下一个格子是否是坚果墙体
if (map.isNutWall(nextGridX, nextGridY)) {
// 流场指引我们走向坚果,说明摧毁代价比绕道低
// 设置攻击状态
attackingWall = true;
attackingWallGridX = nextGridX;
attackingWallGridY = nextGridY;
reservedGridX = nextGridX;
reservedGridY = nextGridY;
reservation = true;
targetX = nextGridX + 0.5f;
targetY = nextGridY + 0.5f;
hasTarget = true;
} else if (map.isWall(nextGridX, nextGridY)) {
nextGridX = currentGridX + (int) Math.signum(dirX); nextGridX = currentGridX + (int) Math.signum(dirX);
nextGridY = currentGridY + (int) Math.signum(dirY); nextGridY = currentGridY + (int) Math.signum(dirY);
if (map.isWall(nextGridX, nextGridY)) { if (map.isWall(nextGridX, nextGridY)) {
if (!map.isWall(currentGridX + (int) Math.signum(dirX), currentGridY)) { if (!map.isWall(currentGridX + (int) Math.signum(dirX), currentGridY)) {
nextGridX = currentGridX + (int) Math.signum(dirX); nextGridX = currentGridX + (int) Math.signum(dirX);
@@ -132,67 +169,86 @@ public class Zombie {
nextGridY = currentGridY + (int) Math.signum(dirY); nextGridY = currentGridY + (int) Math.signum(dirY);
} else { } else {
reservation = false; reservation = false;
return; return null;
} }
} }
}
if (isGridOccupiedOrReserved(nextGridX, nextGridY, otherZombies)) {
if (isGridOccupiedOrReserved(nextGridX, nextGridY, otherZombies)) { int[] altDirs = findAlternativeDirection(currentGridX, currentGridY, dirX, dirY, map, otherZombies);
int[] altDirs = findAlternativeDirection(currentGridX, currentGridY, dirX, dirY, map, otherZombies); if (altDirs != null) {
if (altDirs != null) { nextGridX = altDirs[0];
nextGridX = altDirs[0]; nextGridY = altDirs[1];
nextGridY = altDirs[1]; } else {
} else { reservation = false;
reservation = false; return null;
return; }
} }
reservedGridX = nextGridX;
reservedGridY = nextGridY;
reservation = true;
targetX = nextGridX + 0.5f;
targetY = nextGridY + 0.5f;
hasTarget = true;
} else {
if (isGridOccupiedOrReserved(nextGridX, nextGridY, otherZombies)) {
int[] altDirs = findAlternativeDirection(currentGridX, currentGridY, dirX, dirY, map, otherZombies);
if (altDirs != null) {
nextGridX = altDirs[0];
nextGridY = altDirs[1];
} else {
reservation = false;
return null;
}
}
reservedGridX = nextGridX;
reservedGridY = nextGridY;
reservation = true;
targetX = nextGridX + 0.5f;
targetY = nextGridY + 0.5f;
hasTarget = true;
} }
reservedGridX = nextGridX;
reservedGridY = nextGridY;
reservation = true;
targetX = nextGridX + 0.5f;
targetY = nextGridY + 0.5f;
hasTarget = true;
} }
float dx = targetX - x; float dx = targetX - x;
float dy = targetY - y; float dy = targetY - y;
float dist = (float) Math.sqrt(dx * dx + dy * dy); float dist = (float) Math.sqrt(dx * dx + dy * dy);
if (dist < 0.01f) { if (dist < 0.01f) {
hasTarget = false; hasTarget = false;
return; return null;
} }
float dirX = dx / dist; float dirX = dx / dist;
float dirY = dy / dist; float dirY = dy / dist;
float moveX = dirX * speed * dt; float moveX = dirX * speed * dt;
float moveY = dirY * speed * dt; float moveY = dirY * speed * dt;
float newX = x + moveX; float newX = x + moveX;
float newY = y + moveY; float newY = y + moveY;
boolean canMoveX = map.isWalkable(newX, y, Constants.ZOMBIE_SIZE); boolean canMoveX = map.isWalkable(newX, y, Constants.ZOMBIE_SIZE);
boolean canMoveY = map.isWalkable(x, newY, Constants.ZOMBIE_SIZE); boolean canMoveY = map.isWalkable(x, newY, Constants.ZOMBIE_SIZE);
boolean canMoveDiagonal = map.isWalkable(newX, newY, Constants.ZOMBIE_SIZE); boolean canMoveDiagonal = map.isWalkable(newX, newY, Constants.ZOMBIE_SIZE);
if (moveX != 0 && moveY != 0) { if (moveX != 0 && moveY != 0) {
int checkX = (int) Math.floor(newX); int checkX = (int) Math.floor(newX);
int checkY = (int) Math.floor(newY); int checkY = (int) Math.floor(newY);
int checkCurrentX = (int) Math.floor(x); int checkCurrentX = (int) Math.floor(x);
int checkCurrentY = (int) Math.floor(y); int checkCurrentY = (int) Math.floor(y);
boolean blockedByCorner = false; boolean blockedByCorner = false;
if (checkX != checkCurrentX && checkY != checkCurrentY) { if (checkX != checkCurrentX && checkY != checkCurrentY) {
boolean wallInX = map.isWall(checkX, checkCurrentY); boolean wallInX = map.isWall(checkX, checkCurrentY);
boolean wallInY = map.isWall(checkCurrentX, checkY); boolean wallInY = map.isWall(checkCurrentX, checkY);
if (wallInX || wallInY) { if (wallInX || wallInY) {
blockedByCorner = true; blockedByCorner = true;
if (!wallInX && canMoveX) { if (!wallInX && canMoveX) {
x = newX; x = newX;
canMoveY = map.isWalkable(x, newY, Constants.ZOMBIE_SIZE); canMoveY = map.isWalkable(x, newY, Constants.ZOMBIE_SIZE);
@@ -202,7 +258,7 @@ public class Zombie {
} }
} }
} }
if (!blockedByCorner && canMoveDiagonal) { if (!blockedByCorner && canMoveDiagonal) {
x = newX; x = newX;
y = newY; y = newY;
@@ -214,26 +270,26 @@ public class Zombie {
if (canMoveX) x = newX; if (canMoveX) x = newX;
if (canMoveY) y = newY; if (canMoveY) y = newY;
} }
float minSeparationDist = Constants.ZOMBIE_SIZE; float minSeparationDist = Constants.ZOMBIE_SIZE;
for (Zombie other : otherZombies) { for (Zombie other : otherZombies) {
if (other.getId() == this.id) continue; if (other.getId() == this.id) continue;
if (!other.isAlive()) continue; if (!other.isAlive()) continue;
float ox = other.getX(); float ox = other.getX();
float oy = other.getY(); float oy = other.getY();
float sepDx = x - ox; float sepDx = x - ox;
float sepDy = y - oy; float sepDy = y - oy;
float sepDist = (float) Math.sqrt(sepDx * sepDx + sepDy * sepDy); float sepDist = (float) Math.sqrt(sepDx * sepDx + sepDy * sepDy);
if (sepDist < minSeparationDist && sepDist > 0.01f) { if (sepDist < minSeparationDist && sepDist > 0.01f) {
float overlap = minSeparationDist - sepDist; float overlap = minSeparationDist - sepDist;
float pushX = (sepDx / sepDist) * overlap * 0.5f; float pushX = (sepDx / sepDist) * overlap * 0.5f;
float pushY = (sepDy / sepDist) * overlap * 0.5f; float pushY = (sepDy / sepDist) * overlap * 0.5f;
float pushedX = x + pushX; float pushedX = x + pushX;
float pushedY = y + pushY; float pushedY = y + pushY;
if (map.isWalkable(pushedX, pushedY, Constants.ZOMBIE_SIZE)) { if (map.isWalkable(pushedX, pushedY, Constants.ZOMBIE_SIZE)) {
x = pushedX; x = pushedX;
y = pushedY; y = pushedY;
@@ -247,10 +303,12 @@ public class Zombie {
} }
} }
} }
if (dirX != 0 || dirY != 0) { if (dirX != 0 || dirY != 0) {
angle = (float) Math.atan2(dirX, dirY); angle = (float) Math.atan2(dirX, dirY);
} }
return null;
} }
/** /**

View File

@@ -3,6 +3,7 @@ package com.zombie.game.server;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import com.zombie.game.model.*; import com.zombie.game.model.*;
import com.zombie.game.model.Constants;
import org.java_websocket.WebSocket; import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake; import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer; import org.java_websocket.server.WebSocketServer;

View File

@@ -0,0 +1,160 @@
package com.zombie.game.server;
import com.sun.net.httpserver.*;
import com.google.gson.Gson;
import com.zombie.game.model.MapData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.Map;
/**
* 地图设计器 HTTP API 服务器
*
* 提供REST API用于地图的保存、加载和列表查询
*/
public class MapDesignerApiServer {
private static final Logger logger = LoggerFactory.getLogger(MapDesignerApiServer.class);
private static final int PORT = 8081;
private static final String MAPS_ENDPOINT = "/api/maps";
private static final String MAP_ID_ENDPOINT = "/api/maps/";
private final HttpServer server;
private final Gson gson;
private final MapStorage mapStorage;
public MapDesignerApiServer(MapStorage mapStorage) throws IOException {
this.mapStorage = mapStorage;
this.gson = new Gson();
this.server = HttpServer.create(new InetSocketAddress(PORT), 0);
}
public void start() {
server.createContext(MAPS_ENDPOINT, new MapsHandler());
server.createContext(MAP_ID_ENDPOINT, new MapIdHandler());
server.setExecutor(null);
server.start();
logger.info("Map Designer API Server started on port {}", PORT);
}
public void stop() {
server.stop(0);
logger.info("Map Designer API Server stopped");
}
private class MapsHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
if ("OPTIONS".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 200, "{\"ok\":true}");
return;
}
String method = exchange.getRequestMethod();
try {
switch (method) {
case "GET":
handleList(exchange);
break;
case "POST":
handleSave(exchange);
break;
default:
sendResponse(exchange, 405, "{\"error\":\"Method not allowed\"}");
}
} catch (Exception e) {
logger.error("Error handling request", e);
sendResponse(exchange, 500, "{\"error\":\"" + e.getMessage() + "\"}");
}
}
}
private class MapIdHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getPath();
String id = path.substring(MAP_ID_ENDPOINT.length());
if ("OPTIONS".equals(exchange.getRequestMethod())) {
sendResponse(exchange, 200, "{\"ok\":true}");
return;
}
String method = exchange.getRequestMethod();
try {
switch (method) {
case "GET":
handleLoad(exchange, id);
break;
case "DELETE":
handleDelete(exchange, id);
break;
default:
sendResponse(exchange, 405, "{\"error\":\"Method not allowed\"}");
}
} catch (Exception e) {
logger.error("Error handling request", e);
sendResponse(exchange, 500, "{\"error\":\"" + e.getMessage() + "\"}");
}
}
}
private void handleList(HttpExchange exchange) throws IOException {
List<MapData> maps = mapStorage.listMaps();
String response = gson.toJson(Map.of("maps", maps));
sendJson(exchange, 200, response);
}
private void handleSave(HttpExchange exchange) throws IOException {
java.io.Reader reader = new java.io.InputStreamReader(exchange.getRequestBody());
MapData mapData = gson.fromJson(reader, MapData.class);
MapData saved = mapStorage.save(mapData);
sendJson(exchange, 201, gson.toJson(Map.of("id", saved.getId(), "ok", true)));
}
private void handleLoad(HttpExchange exchange, String id) throws IOException {
MapData mapData = mapStorage.load(id);
if (mapData == null) {
sendResponse(exchange, 404, "{\"error\":\"Map not found\"}");
} else {
sendJson(exchange, 200, gson.toJson(mapData));
}
}
private void handleDelete(HttpExchange exchange, String id) throws IOException {
boolean deleted = mapStorage.delete(id);
if (deleted) {
sendResponse(exchange, 200, "{\"ok\":true}");
} else {
sendResponse(exchange, 404, "{\"error\":\"Map not found\"}");
}
}
private void sendJson(HttpExchange exchange, int statusCode, String json) throws IOException {
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*");
exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type");
byte[] bytes = json.getBytes("UTF-8");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
private void sendResponse(HttpExchange exchange, int statusCode, String response) throws IOException {
byte[] bytes = response.getBytes("UTF-8");
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*");
exchange.sendResponseHeaders(statusCode, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}

View File

@@ -0,0 +1,125 @@
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.*;
/**
* 地图存储服务
*
* 负责将地图数据保存到JSON文件
*/
public class MapStorage {
private static final Logger logger = LoggerFactory.getLogger(MapStorage.class);
private static final String MAPS_DIR = "maps";
private final Gson gson;
private final Map<String, MapData> cache;
public MapStorage() {
this.gson = new Gson();
this.cache = new HashMap<>();
ensureMapsDir();
}
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);
}
}
}
public MapData save(MapData mapData) {
if (mapData.getId() == null || mapData.getId().isEmpty()) {
mapData.setId(UUID.randomUUID().toString().substring(0, 8));
}
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);
}
}
public MapData load(String id) {
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;
}
}
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;
}
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,205 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>地图设计器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #fff; height: 100vh; display: flex; flex-direction: column; }
.header { background: #16213e; padding: 12px 20px; display: flex; align-items: center; gap: 16px; border-bottom: 2px solid #0f3460; }
.header h1 { font-size: 18px; color: #e94560; }
.toolbar { display: flex; gap: 8px; }
.size-set { display: flex; gap: 8px; align-items: center; margin-left: 20px; }
.size-set label { font-size: 14px; }
.size-set input { width: 60px; padding: 4px 8px; background: #0f3460; border: none; color: #fff; border-radius: 4px; }
.size-set button { padding: 6px 12px; background: #e94560; border: none; color: #fff; border-radius: 4px; cursor: pointer; }
.main { flex: 1; display: flex; overflow: hidden; }
.canvas-wrap { flex: 1; display: flex; align-items: center; justify-content: center; background: #0f0f1a; overflow: auto; padding: 20px; }
#canvas { background: #2d2d44; cursor: crosshair; }
.panel { width: 200px; background: #16213e; padding: 16px; border-left: 1px solid #0f3460; display: flex; flex-direction: column; gap: 16px; }
.panel h3 { font-size: 14px; color: #e94560; margin-bottom: 8px; }
.stat { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #0f3460; }
.stat-label { color: #888; }
.actions { display: flex; flex-direction: column; gap: 8px; }
.actions button { padding: 10px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
.btn-save { background: #e94560; color: #fff; }
.btn-clear { background: #16213e; color: #fff; border: 1px solid #0f3460; }
.msg { font-size: 12px; padding: 8px; border-radius: 4px; background: #0f0f1a; }
.msg.error { color: #ff6b6b; }
.msg.ok { color: #4ade80; }
.legend { display: flex; flex-direction: column; gap: 6px; }
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; padding: 4px 6px; border-radius: 4px; }
.legend-item:hover { background: #0f3460; }
.legend-item.active { background: #e94560; }
.legend-swatch { width: 16px; height: 16px; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<h1>地图设计器</h1>
<div class="size-set">
<label>宽:<input type="number" id="w" value="32" min="8" max="128"></label>
<label>高:<input type="number" id="h" value="32" min="8" max="128"></label>
<button id="apply">应用</button>
</div>
</div>
<div class="main">
<div class="canvas-wrap">
<canvas id="canvas"></canvas>
</div>
<div class="panel">
<div>
<h3>统计</h3>
<div class="stat"><span class="stat-label">静态墙</span><span id="s0">0</span></div>
<div class="stat"><span class="stat-label">坚果墙</span><span id="s1">0</span></div>
<div class="stat"><span class="stat-label">玩家点</span><span id="s2">0/4</span></div>
<div class="stat"><span class="stat-label">出怪点</span><span id="s3">0/7</span></div>
</div>
<div>
<h3>放置</h3>
<div class="legend"><div class="legend-item" data-tool="0"><div class="legend-swatch" style="background:#ffffff;border:1px solid #666"></div>空地</div>
<div class="legend-item active" data-tool="1"><div class="legend-swatch" style="background:#4a4a6a"></div>静态墙</div>
<div class="legend-item" data-tool="2"><div class="legend-swatch" style="background:#8B4513"></div>坚果墙</div>
<div class="legend-item" data-tool="3"><div class="legend-swatch" style="background:#e94560"></div>玩家点</div>
<div class="legend-item" data-tool="4"><div class="legend-swatch" style="background:#4ade80"></div>出怪点</div></div>
</div>
<div class="actions">
<button class="btn-clear" id="clear">清空</button>
<button class="btn-save" id="save">保存JSON</button>
</div>
<div class="msg" id="msg">放置至少1个出怪点和1个玩家点</div>
</div>
</div>
<script>
const COLORS = ['#ffffff','#4a4a6a','#8B4513','#e94560','#4ade80'];
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let w = 32, h = 32, cs = 16, tool = 1;
let cells = [];
function init() {
cells = [];
for (let y = 0; y < h; y++) {
cells[y] = new Array(w).fill(0);
}
canvas.width = w * cs;
canvas.height = h * cs;
draw();
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
ctx.fillStyle = COLORS[cells[y][x]];
ctx.fillRect(x * cs, y * cs, cs, cs);
ctx.strokeStyle = '#222';
ctx.strokeRect(x * cs, y * cs, cs, cs);
}
}
updateStats();
}
function updateStats() {
let c = [0,0,0,0,0];
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) c[cells[y][x]]++;
document.getElementById('s0').textContent = c[1];
document.getElementById('s1').textContent = c[2];
document.getElementById('s2').textContent = c[3] + '/4';
document.getElementById('s3').textContent = c[4] + '/7';
const msg = document.getElementById('msg');
if (c[3] < 1 || c[4] < 1) {
msg.className = 'msg error';
msg.textContent = '需要至少1个出怪点和1个玩家点';
document.getElementById('save').disabled = true;
} else if (c[3] > 4 || c[4] > 7) {
msg.className = 'msg error';
msg.textContent = c[3] > 4 ? '玩家点最多4个' : '出怪点最多7个';
document.getElementById('save').disabled = true;
} else {
msg.className = 'msg ok';
msg.textContent = '可以保存';
document.getElementById('save').disabled = false;
}
}
function getCell(e) {
const r = canvas.getBoundingClientRect();
const x = Math.floor((e.clientX - r.left) / cs);
const y = Math.floor((e.clientY - r.top) / cs);
if (x >= 0 && x < w && y >= 0 && y < h) return { x, y };
return null;
}
canvas.addEventListener('mousedown', e => {
const c = getCell(e);
if (c) { cells[c.y][c.x] = tool; draw(); }
});
canvas.addEventListener('mousemove', e => {
if (e.buttons) {
const c = getCell(e);
if (c) { cells[c.y][c.x] = tool; draw(); }
}
});
canvas.addEventListener('contextmenu', e => {
e.preventDefault();
const c = getCell(e);
if (c) { cells[c.y][c.x] = 0; draw(); }
});
document.querySelectorAll('.legend-item').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.legend-item').forEach(x => x.classList.remove('active'));
b.classList.add('active');
tool = parseInt(b.dataset.tool);
});
});
document.getElementById('apply').addEventListener('click', () => {
const nw = parseInt(document.getElementById('w').value) || 32;
const nh = parseInt(document.getElementById('h').value) || 32;
w = Math.max(8, Math.min(128, nw));
h = Math.max(8, Math.min(128, nh));
document.getElementById('w').value = w;
document.getElementById('h').value = h;
cs = Math.max(4, Math.min(20, Math.floor(600 / Math.max(w, h))));
init();
});
document.getElementById('clear').addEventListener('click', () => {
if (confirm('清空?')) init();
});
document.getElementById('save').addEventListener('click', async () => {
const name = prompt('地图名称:', 'my-map') || 'my-map';
const walls = [], ps = [], zs = [];
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (cells[y][x] === 1) walls.push({x, y, type: 'static'});
if (cells[y][x] === 2) walls.push({x, y, type: 'nut'});
if (cells[y][x] === 3) ps.push({x, y});
if (cells[y][x] === 4) zs.push({x, y});
}
}
const data = { name, width: w, height: h, walls, playerSpawns: ps, zombieSpawns: zs };
try {
const resp = await fetch('/api/maps', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (!resp.ok) throw new Error('保存失败');
const result = await resp.json();
alert('保存成功ID: ' + result.id);
} catch (e) {
alert('保存失败: ' + e.message);
}
});
init();
</script>
</body>
</html>

View File

@@ -524,6 +524,11 @@ export class GameEngine {
} }
} }
// 更新坚果墙体
if (state.nutWalls !== undefined) {
this.scene.updateNutWalls(state.nutWalls)
}
// 更新游戏状态 // 更新游戏状态
if (state.gameTime !== undefined) this.gameTime = state.gameTime if (state.gameTime !== undefined) this.gameTime = state.gameTime
if (state.waveNumber !== undefined) this.waveNumber = state.waveNumber if (state.waveNumber !== undefined) this.waveNumber = state.waveNumber

View File

@@ -33,6 +33,7 @@ export class GameScene {
this.loots = new Map() // Map<lootId, {mesh, type}> this.loots = new Map() // Map<lootId, {mesh, type}>
this.effects = [] // 特效数组 this.effects = [] // 特效数组
this.wallMeshes = [] // 墙壁网格 this.wallMeshes = [] // 墙壁网格
this.nutWalls = new Map() // Map<key, {mesh, healthBar}>
// 摄像机辅助 // 摄像机辅助
this.gridHelper = null this.gridHelper = null
@@ -228,6 +229,10 @@ export class GameScene {
const zombieSpawnGeo = new THREE.BoxGeometry(1, 0.15, 1) const zombieSpawnGeo = new THREE.BoxGeometry(1, 0.15, 1)
const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4400, transparent: true, opacity: 0.7 }) const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4400, transparent: true, opacity: 0.7 })
// 坚果墙体
const nutWallGeo = new THREE.BoxGeometry(1, 1.2, 1)
const nutWallMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 })
// 遍历地图数据 // 遍历地图数据
for (let y = 0; y < GRID_SIZE; y++) { for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) { for (let x = 0; x < GRID_SIZE; x++) {
@@ -256,6 +261,82 @@ export class GameScene {
} }
} }
/**
* 更新坚果墙体状态
* @param {Array} nutWalls 坚果墙体状态数组 [{x, y, health, maxHealth}]
*/
updateNutWalls(nutWalls) {
const currentKeys = new Set()
for (const wall of nutWalls) {
const key = `${wall.x},${wall.y}`
currentKeys.add(key)
if (this.nutWalls.has(key)) {
// 更新已有墙体的血量表现
const data = this.nutWalls.get(key)
const healthPercent = wall.health / wall.maxHealth
// 根据血量改变颜色:满血棕色 -> 低血红棕色
const r = 0.545 + (1 - healthPercent) * 0.4
const g = 0.271 - (1 - healthPercent) * 0.1
const b = 0.075 - (1 - healthPercent) * 0.05
data.mesh.material.color.setRGB(r, g, b)
// 更新血条
if (data.healthBar) {
data.healthBar.scale.x = healthPercent
data.healthBar.material.color.setHex(healthPercent > 0.5 ? 0x00ff00 : healthPercent > 0.25 ? 0xffff00 : 0xff0000)
}
} else {
// 创建新的坚果墙体
const nutWallGeo = new THREE.BoxGeometry(1, 1.2, 1)
const nutWallMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 })
const mesh = new THREE.Mesh(nutWallGeo, nutWallMat)
mesh.position.set(wall.x + 0.5, 0.6, wall.y + 0.5)
mesh.castShadow = true
mesh.receiveShadow = true
this.scene.add(mesh)
// 创建血条背景
const barBgGeo = new THREE.PlaneGeometry(0.8, 0.1)
const barBgMat = new THREE.MeshBasicMaterial({ color: 0x333333 })
const barBg = new THREE.Mesh(barBgGeo, barBgMat)
barBg.position.set(wall.x + 0.5, 1.3, wall.y + 0.5)
barBg.rotation.x = -Math.PI / 2
this.scene.add(barBg)
// 创建血条前景
const barFgGeo = new THREE.PlaneGeometry(0.8, 0.1)
const barFgMat = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
const barFg = new THREE.Mesh(barFgGeo, barFgMat)
barFg.position.set(wall.x + 0.5, 1.31, wall.y + 0.5)
barFg.rotation.x = -Math.PI / 2
this.scene.add(barFg)
this.nutWalls.set(key, { mesh, healthBar: barFg, barBg })
}
}
// 移除已经不存在的墙体
for (const [key, data] of this.nutWalls) {
if (!currentKeys.has(key)) {
this.scene.remove(data.mesh)
data.mesh.geometry.dispose()
data.mesh.material.dispose()
if (data.healthBar) {
this.scene.remove(data.healthBar)
data.healthBar.geometry.dispose()
data.healthBar.material.dispose()
}
if (data.barBg) {
this.scene.remove(data.barBg)
data.barBg.geometry.dispose()
data.barBg.material.dispose()
}
this.nutWalls.delete(key)
}
}
}
/** /**
* 创建玩家3D模型 * 创建玩家3D模型
* @param {number} color 玩家颜色 * @param {number} color 玩家颜色

View File

@@ -8,6 +8,10 @@ export default defineConfig({
'/ws': { '/ws': {
target: 'ws://localhost:8080', target: 'ws://localhost:8080',
ws: true ws: true
},
'/api': {
target: 'http://localhost:8081',
changeOrigin: true
} }
} }
} }

1
maps/d540209a.json Normal file
View File

@@ -0,0 +1 @@
{"id":"d540209a","name":"my-map","width":32,"height":32,"walls":[{"x":0.0,"y":0.0,"type":"static"},{"x":0.0,"y":1.0,"type":"static"},{"x":0.0,"y":2.0,"type":"static"},{"x":0.0,"y":3.0,"type":"static"},{"x":0.0,"y":4.0,"type":"static"},{"x":0.0,"y":5.0,"type":"static"},{"x":0.0,"y":6.0,"type":"static"},{"x":0.0,"y":7.0,"type":"static"},{"x":23.0,"y":7.0,"type":"static"},{"x":0.0,"y":8.0,"type":"static"},{"x":8.0,"y":8.0,"type":"nut"},{"x":9.0,"y":8.0,"type":"nut"},{"x":10.0,"y":8.0,"type":"nut"},{"x":11.0,"y":8.0,"type":"nut"},{"x":12.0,"y":8.0,"type":"nut"},{"x":13.0,"y":8.0,"type":"nut"},{"x":14.0,"y":8.0,"type":"nut"},{"x":15.0,"y":8.0,"type":"nut"},{"x":16.0,"y":8.0,"type":"nut"},{"x":17.0,"y":8.0,"type":"nut"},{"x":18.0,"y":8.0,"type":"nut"},{"x":19.0,"y":8.0,"type":"nut"},{"x":20.0,"y":8.0,"type":"nut"},{"x":21.0,"y":8.0,"type":"nut"},{"x":22.0,"y":8.0,"type":"nut"},{"x":23.0,"y":8.0,"type":"static"},{"x":24.0,"y":8.0,"type":"static"},{"x":0.0,"y":9.0,"type":"static"},{"x":24.0,"y":9.0,"type":"static"},{"x":0.0,"y":10.0,"type":"static"},{"x":24.0,"y":10.0,"type":"static"},{"x":29.0,"y":10.0,"type":"static"},{"x":0.0,"y":11.0,"type":"static"},{"x":1.0,"y":11.0,"type":"nut"},{"x":2.0,"y":11.0,"type":"nut"},{"x":3.0,"y":11.0,"type":"nut"},{"x":4.0,"y":11.0,"type":"nut"},{"x":5.0,"y":11.0,"type":"nut"},{"x":6.0,"y":11.0,"type":"nut"},{"x":7.0,"y":11.0,"type":"nut"},{"x":8.0,"y":11.0,"type":"nut"},{"x":9.0,"y":11.0,"type":"nut"},{"x":10.0,"y":11.0,"type":"nut"},{"x":11.0,"y":11.0,"type":"nut"},{"x":12.0,"y":11.0,"type":"nut"},{"x":13.0,"y":11.0,"type":"nut"},{"x":14.0,"y":11.0,"type":"nut"},{"x":15.0,"y":11.0,"type":"nut"},{"x":16.0,"y":11.0,"type":"nut"},{"x":24.0,"y":11.0,"type":"static"},{"x":29.0,"y":11.0,"type":"static"},{"x":0.0,"y":12.0,"type":"static"},{"x":24.0,"y":12.0,"type":"static"},{"x":0.0,"y":13.0,"type":"static"},{"x":7.0,"y":13.0,"type":"nut"},{"x":8.0,"y":13.0,"type":"nut"},{"x":9.0,"y":13.0,"type":"nut"},{"x":10.0,"y":13.0,"type":"nut"},{"x":11.0,"y":13.0,"type":"nut"},{"x":12.0,"y":13.0,"type":"nut"},{"x":13.0,"y":13.0,"type":"nut"},{"x":14.0,"y":13.0,"type":"nut"},{"x":15.0,"y":13.0,"type":"nut"},{"x":16.0,"y":13.0,"type":"nut"},{"x":17.0,"y":13.0,"type":"nut"},{"x":18.0,"y":13.0,"type":"nut"},{"x":19.0,"y":13.0,"type":"nut"},{"x":20.0,"y":13.0,"type":"nut"},{"x":21.0,"y":13.0,"type":"nut"},{"x":22.0,"y":13.0,"type":"nut"},{"x":23.0,"y":13.0,"type":"nut"},{"x":24.0,"y":13.0,"type":"static"},{"x":29.0,"y":13.0,"type":"static"},{"x":0.0,"y":14.0,"type":"static"},{"x":7.0,"y":14.0,"type":"nut"},{"x":24.0,"y":14.0,"type":"static"},{"x":29.0,"y":14.0,"type":"static"},{"x":24.0,"y":15.0,"type":"static"},{"x":29.0,"y":15.0,"type":"static"},{"x":24.0,"y":16.0,"type":"static"},{"x":0.0,"y":17.0,"type":"static"},{"x":1.0,"y":17.0,"type":"static"},{"x":2.0,"y":17.0,"type":"static"},{"x":3.0,"y":17.0,"type":"static"},{"x":6.0,"y":17.0,"type":"static"},{"x":7.0,"y":17.0,"type":"static"},{"x":10.0,"y":17.0,"type":"static"},{"x":11.0,"y":17.0,"type":"static"},{"x":12.0,"y":17.0,"type":"static"},{"x":13.0,"y":17.0,"type":"static"},{"x":14.0,"y":17.0,"type":"static"},{"x":15.0,"y":17.0,"type":"static"},{"x":16.0,"y":17.0,"type":"static"},{"x":17.0,"y":17.0,"type":"static"},{"x":18.0,"y":17.0,"type":"static"},{"x":19.0,"y":17.0,"type":"static"},{"x":20.0,"y":17.0,"type":"static"},{"x":21.0,"y":17.0,"type":"static"},{"x":22.0,"y":17.0,"type":"static"},{"x":23.0,"y":17.0,"type":"static"},{"x":24.0,"y":17.0,"type":"static"},{"x":29.0,"y":17.0,"type":"static"},{"x":29.0,"y":19.0,"type":"static"},{"x":29.0,"y":20.0,"type":"static"},{"x":29.0,"y":21.0,"type":"static"},{"x":29.0,"y":22.0,"type":"static"},{"x":28.0,"y":23.0,"type":"static"},{"x":29.0,"y":23.0,"type":"static"},{"x":28.0,"y":24.0,"type":"static"},{"x":9.0,"y":25.0,"type":"static"},{"x":10.0,"y":25.0,"type":"static"},{"x":11.0,"y":25.0,"type":"static"},{"x":12.0,"y":25.0,"type":"static"},{"x":13.0,"y":25.0,"type":"static"},{"x":14.0,"y":25.0,"type":"static"},{"x":15.0,"y":25.0,"type":"static"},{"x":28.0,"y":25.0,"type":"static"},{"x":15.0,"y":26.0,"type":"static"},{"x":17.0,"y":26.0,"type":"static"},{"x":18.0,"y":26.0,"type":"static"},{"x":20.0,"y":26.0,"type":"static"},{"x":22.0,"y":26.0,"type":"static"},{"x":24.0,"y":26.0,"type":"static"},{"x":25.0,"y":26.0,"type":"static"},{"x":26.0,"y":26.0,"type":"static"},{"x":27.0,"y":26.0,"type":"static"},{"x":28.0,"y":26.0,"type":"static"}],"playerSpawns":[{"x":4,"y":1},{"x":9,"y":2},{"x":14,"y":2},{"x":24,"y":2}],"zombieSpawns":[{"x":23,"y":15},{"x":6,"y":21}]}