This commit is contained in:
wfz
2026-04-15 20:58:16 +08:00
commit 8ab9a9fa70
33 changed files with 6140 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/frontend/dist/
/frontend/node_modules/
/backend/target/
/.idea/

35
backend/build-and-run.bat Normal file
View File

@@ -0,0 +1,35 @@
@echo off
echo ========================================
echo Zombie Crisis 3 - Build and Run Script
echo ========================================
echo.
set MAVEN_VERSION=3.9.6
set MAVEN_DIR=%~dp0.maven
if not exist "%MAVEN_DIR%\apache-maven-%MAVEN_VERSION%" (
echo Maven not found. Downloading Maven %MAVEN_VERSION%...
powershell -Command "Invoke-WebRequest -Uri 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/%MAVEN_VERSION%/apache-maven-%MAVEN_VERSION%-bin.zip' -OutFile '%MAVEN_DIR%\maven.zip'"
if not exist "%MAVEN_DIR%" mkdir "%MAVEN_DIR%"
echo Extracting Maven...
powershell -Command "Expand-Archive -Path '%MAVEN_DIR%\maven.zip' -DestinationPath '%MAVEN_DIR%' -Force"
del "%MAVEN_DIR%\maven.zip"
)
set MAVEN_HOME=%MAVEN_DIR%\apache-maven-%MAVEN_VERSION%
set PATH=%MAVEN_HOME%\bin;%PATH%
echo Building server...
call mvn clean package -DskipTests
if %ERRORLEVEL% NEQ 0 (
echo Build failed!
pause
exit /b 1
)
echo.
echo Starting server on port 8080...
java -jar target\zombie-crisis-server-1.0.0.jar
pause

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.zombie</groupId>
<artifactId>zombie-crisis-server</artifactId>
<version>1.0.0</version>
<build>
<plugins>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.zombie.game.GameServerMain</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<properties>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.source>17</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

61
backend/mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,61 @@
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.2.0
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $proxy=''; [Net.WebRequest]::DefaultWebProxy.Credentials=[Net.CredentialCache]::DefaultCredentials; if('%MVNW_USERNAME%' -ne '' -and '%MVNW_PASSWORD%' -ne ''){$proxy=[Net.WebRequest]::GetSystemWebProxy(); $proxy.Credentials=[Net.NetworkCredential]::new('%MVNW_USERNAME%','%MVNW_PASSWORD%');} try{Invoke-WebRequest -Uri ('https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar') -Proxy $proxy -ProxyUseDefaultCredentials -OutFile ('%__MVNW_JAR__%')}catch{Write-Error $_}}" 2>&1`) DO @(
IF "%%A"=="MVNW_ERROR" (
SET __MVNW_ERROR__=%%B
)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
@IF NOT "%MAVEN_PROJECTBASEDIR%"=="" GOTO endDetectBaseDir
@SET MAVEN_PROJECTBASEDIR=%~dp0..
:endDetectBaseDir
@IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" (
powershell -noprofile "& {Invoke-WebRequest -Uri 'https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar' -OutFile '%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar'}"
)
@SET MAVEN_CMD_LINE_ARGS=%*
@REM Find Java
@SET JAVA_EXE=java.exe
@IF DEFINED JAVA_HOME (
@SET JAVA_HOME=%JAVA_HOME:"=%
@SET JAVA_EXE=%JAVA_HOME%/bin/java.exe
)
@REM Execute Maven wrapper
"%JAVA_EXE%" ^
%JVM_CONFIG_MAVEN_PROPS% ^
%MAVEN_OPTS% ^
%MAVEN_DEBUG_OPTS% ^
-classpath "%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" ^
"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
org.apache.maven.wrapper.MavenWrapperMain %MAVEN_CMD_LINE_ARGS%

65
backend/pom.xml Normal file
View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.zombie</groupId>
<artifactId>zombie-crisis-server</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.4</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.zombie.game.GameServerMain</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,31 @@
package com.zombie.game;
import com.zombie.game.server.GameWebSocketServer;
public class GameServerMain {
public static void main(String[] args) {
int port = 8080;
if (args.length > 0) {
try {
port = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
System.err.println("Invalid port number, using default: 8080");
}
}
GameWebSocketServer server = new GameWebSocketServer(port);
server.start();
System.out.println("Zombie Crisis 3 Server started on port " + port);
System.out.println("Press Ctrl+C to stop the server");
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Shutting down server...");
try {
server.stop();
} catch (Exception e) {
e.printStackTrace();
}
}));
}
}

View File

@@ -0,0 +1,150 @@
package com.zombie.game.model;
import java.util.*;
public class Bullet {
private int id;
private float x, y;
private float z;
private float vx, vy, vz;
private float speed;
private int damage;
private String ownerId;
private String weapon;
private float range;
private float distanceTraveled;
private boolean explosive;
private float explosionRadius;
private float flightTime;
private float maxFlightTime;
private float targetX, targetY;
private boolean isGrenade;
public Bullet(int id, float x, float y, float angle, float speed, int damage,
String ownerId, String weapon, float range) {
this.id = id;
this.x = x;
this.y = y;
this.z = 0.5f;
this.speed = speed;
this.vx = (float) Math.sin(angle) * speed;
this.vy = (float) Math.cos(angle) * speed;
this.vz = 0;
this.damage = damage;
this.ownerId = ownerId;
this.weapon = weapon;
this.range = range;
this.distanceTraveled = 0;
this.explosive = weapon.equals(Constants.WEAPON_GRENADE);
this.explosionRadius = explosive ? 3.0f : 0;
this.flightTime = 0;
this.maxFlightTime = 0;
this.isGrenade = weapon.equals(Constants.WEAPON_GRENADE);
this.targetX = x + (float) Math.sin(angle) * range;
this.targetY = y + (float) Math.cos(angle) * range;
}
public Bullet(int id, float startX, float startY, float targetX, float targetY,
float flightDuration, int damage, String ownerId, float explosionRadius) {
this.id = id;
this.x = startX;
this.y = startY;
this.z = 0.5f;
this.targetX = targetX;
this.targetY = targetY;
this.damage = damage;
this.ownerId = ownerId;
this.weapon = Constants.WEAPON_GRENADE;
this.range = 0;
this.distanceTraveled = 0;
this.explosive = true;
this.explosionRadius = explosionRadius;
this.flightTime = 0;
this.maxFlightTime = flightDuration;
this.isGrenade = true;
this.speed = 0;
float dx = targetX - startX;
float dy = targetY - startY;
this.vx = dx / flightDuration;
this.vy = dy / flightDuration;
this.vz = 3.0f;
}
public int getId() { return id; }
public float getX() { return x; }
public float getY() { return y; }
public float getZ() { return z; }
public float getVx() { return vx; }
public float getVy() { return vy; }
public int getDamage() { return damage; }
public String getOwnerId() { return ownerId; }
public String getWeapon() { return weapon; }
public boolean isExplosive() { return explosive; }
public float getExplosionRadius() { return explosionRadius; }
public float getTargetX() { return targetX; }
public float getTargetY() { return targetY; }
public boolean isGrenade() { return isGrenade; }
public boolean update(float dt, GameMap map) {
if (isGrenade) {
flightTime += dt;
x += vx * dt;
y += vy * dt;
float progress = flightTime / maxFlightTime;
z = 0.5f + 4.0f * (float) Math.sin(progress * Math.PI);
if (flightTime >= maxFlightTime || z <= 0.5f && progress > 0.5f) {
return false;
}
if (x < 0 || x >= Constants.GRID_SIZE || y < 0 || y >= Constants.GRID_SIZE) {
return false;
}
return true;
} else {
float moveX = vx * dt;
float moveY = vy * dt;
x += moveX;
y += moveY;
distanceTraveled += (float) Math.sqrt(moveX * moveX + moveY * moveY);
if (distanceTraveled >= range) return false;
if (x < 0 || x >= Constants.GRID_SIZE || y < 0 || y >= Constants.GRID_SIZE) return false;
int gx = (int) Math.floor(x);
int gy = (int) Math.floor(y);
if (map.isWall(gx, gy)) return false;
return true;
}
}
public boolean hitsEntity(float ex, float ey, float size) {
float dx = x - ex;
float dy = y - ey;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
return dist < size / 2 + 0.1f;
}
public Map<String, Object> toStateMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", id);
map.put("x", x);
map.put("y", y);
map.put("z", z);
if (isGrenade) {
map.put("angle", (float) Math.atan2(vx, vy));
map.put("targetX", targetX);
map.put("targetY", targetY);
} else {
map.put("angle", (float) Math.atan2(vx, vy));
}
map.put("weapon", weapon);
map.put("ownerId", ownerId);
return map;
}
}

View File

@@ -0,0 +1,56 @@
package com.zombie.game.model;
public class Constants {
public static final int GRID_SIZE = 32;
public static final float PLAYER_SIZE = 0.8f;
public static final float ZOMBIE_SIZE = 0.8f;
public static final int TICK_RATE = 30;
public static final float TICK_INTERVAL = 1.0f / TICK_RATE;
public static final float PLAYER_SPEED = 5.0f;
public static final float PLAYER_MAX_HEALTH = 100;
public static final float PLAYER_INVULNERABLE_TIME = 0.5f;
public static final float ZOMBIE_BASE_HEALTH = 100;
public static final float ZOMBIE_BASE_SPEED = 2.0f;
public static final float ZOMBIE_DAMAGE = 10;
public static final float ZOMBIE_ATTACK_RATE = 1.0f;
public static final float ZOMBIE_LOOT_DROP_CHANCE = 0.3f;
public static final float ZOMBIE_SPAWN_INTERVAL_BASE = 3.0f;
public static final float ZOMBIE_SPAWN_INTERVAL_MIN = 0.8f;
public static final float ZOMBIE_HEALTH_INCREASE = 20;
public static final float ZOMBIE_SPEED_INCREASE = 0.1f;
public static final float DIFFICULTY_INCREASE_INTERVAL = 30.0f;
public static final float ELITE_ZOMBIE_HEALTH = 800;
public static final float ELITE_ZOMBIE_SPEED = 1.5f;
public static final float ELITE_ZOMBIE_DAMAGE = 20;
public static final float ELITE_ZOMBIE_ATTACK_RANGE = 8.0f;
public static final float ELITE_ZOMBIE_ATTACK_RATE = 2.0f;
public static final int ELITE_ZOMBIE_BULLET_DAMAGE = 30;
public static final float ELITE_ZOMBIE_BULLET_SPEED = 6.0f;
public static final float ELITE_ZOMBIE_SPAWN_CHANCE = 0.05f;
public static final String WEAPON_PISTOL = "pistol";
public static final String WEAPON_MACHINE_GUN = "machine_gun";
public static final String WEAPON_SHOTGUN = "shotgun";
public static final String WEAPON_GRENADE = "grenade";
public static final String LOOT_TYPE_AMMO = "ammo";
public static final String LOOT_TYPE_HEALTH = "health";
public static final float LOOT_HEALTH_AMOUNT = 30;
public static final String MSG_CREATE_ROOM = "create_room";
public static final String MSG_JOIN_ROOM = "join_room";
public static final String MSG_LEAVE_ROOM = "leave_room";
public static final String MSG_ROOM_LIST = "room_list";
public static final String MSG_ROOM_STATE = "room_state";
public static final String MSG_READY = "ready";
public static final String MSG_START_GAME = "start_game";
public static final String MSG_GAME_STARTED = "game_started";
public static final String MSG_PLAYER_INPUT = "player_input";
public static final String MSG_GAME_STATE = "game_state";
public static final String MSG_PLAYER_JOIN = "player_join";
public static final String MSG_PLAYER_LEAVE = "player_leave";
public static final String MSG_ERROR = "error";
}

View File

@@ -0,0 +1,216 @@
package com.zombie.game.model;
import java.util.*;
public class GameMap {
private final int[][] cells;
private final int width;
private final int height;
public GameMap() {
this.width = Constants.GRID_SIZE;
this.height = Constants.GRID_SIZE;
this.cells = new int[height][width];
generateDefaultMap();
}
private void generateDefaultMap() {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (x == 0 || x == width - 1 || y == 0 || y == height - 1) {
cells[y][x] = 1;
} else {
cells[y][x] = 0;
}
}
}
int[][] wallSegments = {
{5, 5, 5, 10}, {10, 3, 15, 3}, {20, 5, 20, 12},
{8, 15, 14, 15}, {25, 10, 25, 18}, {3, 20, 8, 20},
{15, 20, 15, 26}, {22, 22, 28, 22}, {5, 25, 10, 25},
{12, 8, 12, 12}, {18, 16, 22, 16}, {27, 5, 27, 9},
{8, 27, 13, 27}, {18, 26, 18, 30}
};
for (int[] seg : wallSegments) {
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) {
cells[y][seg[0]] = 1;
}
}
} else {
for (int x = seg[0]; x <= seg[2]; x++) {
if (x > 0 && x < width - 1 && seg[1] > 0 && seg[1] < height - 1) {
cells[seg[1]][x] = 1;
}
}
}
}
int[][] spawnPoints = {{2, 2}, {29, 2}, {2, 29}, {29, 29}};
for (int[] sp : spawnPoints) {
cells[sp[1]][sp[0]] = 2;
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) {
if (cells[ny][nx] == 1) cells[ny][nx] = 0;
}
}
}
}
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) {
if (cells[ny][nx] == 1) cells[ny][nx] = 0;
}
}
}
}
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) {
if (cells[ny][nx] == 1) cells[ny][nx] = 0;
}
}
}
}
}
public boolean isWall(int gx, int gy) {
if (gx < 0 || gx >= width || gy < 0 || gy >= height) return true;
return cells[gy][gx] == 1;
}
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;
}
public int[][] getCells() {
return cells;
}
public List<int[]> getSpawnPoints() {
List<int[]> points = new ArrayList<>();
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (cells[y][x] == 2) {
points.add(new int[]{x, y});
}
}
}
return points;
}
public List<int[]> getZombieSpawnPoints() {
List<int[]> points = new ArrayList<>();
int[][] zombieSpawns = {{8, 8}, {24, 24}};
for (int[] sp : zombieSpawns) {
points.add(sp);
}
return points;
}
public List<float[]> findPath(float startX, float startY, float endX, float endY) {
int sgx = (int) Math.floor(startX);
int sgy = (int) Math.floor(startY);
int egx = (int) Math.floor(endX);
int egy = (int) Math.floor(endY);
if (isWall(egx, egy)) return null;
PriorityQueue<PathNode> openSet = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f));
Set<String> closedSet = new HashSet<>();
Map<String, String> cameFrom = new HashMap<>();
String startKey = sgx + "," + sgy;
double h = Math.abs(sgx - egx) + Math.abs(sgy - egy);
openSet.add(new PathNode(sgx, sgy, 0, h));
int[][] dirs = {{0, -1}, {0, 1}, {-1, 0}, {1, 0}, {-1, -1}, {1, -1}, {-1, 1}, {1, 1}};
int iterations = 0;
while (!openSet.isEmpty() && iterations < 2000) {
iterations++;
PathNode current = openSet.poll();
String currentKey = current.x + "," + current.y;
if (current.x == egx && current.y == egy) {
List<float[]> path = new ArrayList<>();
String key = currentKey;
while (cameFrom.containsKey(key)) {
String[] parts = key.split(",");
int cx = Integer.parseInt(parts[0]);
int cy = Integer.parseInt(parts[1]);
path.add(0, new float[]{cx + 0.5f, cy + 0.5f});
key = cameFrom.get(key);
}
return path;
}
closedSet.add(currentKey);
for (int[] dir : dirs) {
int nx = current.x + dir[0];
int ny = current.y + dir[1];
String nKey = nx + "," + ny;
if (closedSet.contains(nKey)) continue;
if (isWall(nx, ny)) continue;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
if (dir[0] != 0 && dir[1] != 0) {
if (isWall(current.x + dir[0], current.y) || isWall(current.x, current.y + dir[1])) {
continue;
}
}
boolean diagonal = dir[0] != 0 && dir[1] != 0;
double moveCost = diagonal ? 1.414 : 1.0;
double g = current.g + moveCost;
double nh = Math.abs(nx - egx) + Math.abs(ny - egy);
openSet.add(new PathNode(nx, ny, g, nh));
if (!cameFrom.containsKey(nKey)) {
cameFrom.put(nKey, currentKey);
}
}
}
return null;
}
private static class PathNode {
int x, y;
double g, h, f;
PathNode(int x, int y, double g, double h) {
this.x = x;
this.y = y;
this.g = g;
this.h = h;
this.f = g + h;
}
}
}

View File

@@ -0,0 +1,448 @@
package com.zombie.game.model;
import java.util.*;
public class GameWorld {
private GameMap map;
private Map<String, Player> players;
private Map<Integer, Zombie> zombies;
private Map<Integer, Bullet> bullets;
private Map<Integer, Bullet> zombieBullets;
private Map<Integer, Loot> loots;
private int nextBulletId;
private int nextZombieId;
private int nextLootId;
private int nextZombieBulletId;
private float gameTime;
private int waveNumber;
private int score;
private float spawnTimer;
private float difficultyTimer;
private float zombieHealth;
private float zombieSpeed;
private float spawnInterval;
private Random random;
private List<Map<String, Object>> explosions;
private List<Integer> removedBullets;
private List<Integer> removedZombieBullets;
public GameWorld() {
this.map = new GameMap();
this.players = new LinkedHashMap<>();
this.zombies = new LinkedHashMap<>();
this.bullets = new LinkedHashMap<>();
this.zombieBullets = new LinkedHashMap<>();
this.loots = new LinkedHashMap<>();
this.nextBulletId = 0;
this.nextZombieId = 0;
this.nextLootId = 0;
this.nextZombieBulletId = 0;
this.gameTime = 0;
this.waveNumber = 0;
this.score = 0;
this.spawnTimer = 0;
this.difficultyTimer = 0;
this.zombieHealth = Constants.ZOMBIE_BASE_HEALTH;
this.zombieSpeed = Constants.ZOMBIE_BASE_SPEED;
this.spawnInterval = Constants.ZOMBIE_SPAWN_INTERVAL_BASE;
this.random = new Random();
this.explosions = new ArrayList<>();
this.removedBullets = new ArrayList<>();
this.removedZombieBullets = new ArrayList<>();
}
public GameMap getMap() { return map; }
public void addPlayer(Player player) {
List<int[]> spawnPoints = map.getSpawnPoints();
int idx = players.size() % spawnPoints.size();
int[] sp = spawnPoints.get(idx);
float wx = sp[0] + 0.5f;
float wy = sp[1] + 0.5f;
player.applyMovement(0, 0, map);
players.put(player.getId(), player);
}
public void removePlayer(String playerId) {
players.remove(playerId);
}
public Player getPlayer(String id) { return players.get(id); }
public Collection<Player> getPlayers() { return players.values(); }
public Collection<Zombie> getZombies() { return zombies.values(); }
public Collection<Bullet> getBullets() { return bullets.values(); }
public Collection<Loot> getLoots() { return loots.values(); }
public void update(float dt) {
explosions.clear();
removedBullets.clear();
removedZombieBullets.clear();
gameTime += dt;
spawnTimer += dt;
difficultyTimer += dt;
if (difficultyTimer >= Constants.DIFFICULTY_INCREASE_INTERVAL) {
difficultyTimer -= Constants.DIFFICULTY_INCREASE_INTERVAL;
waveNumber++;
spawnInterval = Math.max(Constants.ZOMBIE_SPAWN_INTERVAL_MIN,
spawnInterval - 0.3f);
}
if (spawnTimer >= spawnInterval) {
spawnTimer -= spawnInterval;
spawnZombie();
}
updateZombies(dt);
updateBullets(dt);
updateZombieBullets(dt);
checkBulletCollisions();
checkZombieBulletCollisions();
checkZombieAttacks();
checkLootCollection();
}
private void spawnZombie() {
List<int[]> spawnPoints = map.getZombieSpawnPoints();
int[] sp = spawnPoints.get(random.nextInt(spawnPoints.size()));
float wx = sp[0] + 0.5f;
float wy = sp[1] + 0.5f;
Player nearest = findNearestPlayer(wx, wy);
if (nearest == null) return;
boolean isElite = random.nextFloat() < Constants.ELITE_ZOMBIE_SPAWN_CHANCE;
Zombie zombie;
if (isElite) {
zombie = new Zombie(nextZombieId++, wx, wy, Constants.ELITE_ZOMBIE_HEALTH, Constants.ELITE_ZOMBIE_SPEED, true);
} else {
zombie = new Zombie(nextZombieId++, wx, wy, zombieHealth, zombieSpeed, false);
}
zombie.updatePath(map, nearest.getX(), nearest.getY());
zombies.put(zombie.getId(), zombie);
}
private Player findNearestPlayer(float x, float y) {
Player nearest = null;
float minDist = Float.MAX_VALUE;
for (Player p : players.values()) {
if (!p.isAlive()) continue;
float dist = p.distanceTo(x, y);
if (dist < minDist) {
minDist = dist;
nearest = p;
}
}
return nearest;
}
private void updateZombies(float dt) {
long now = System.currentTimeMillis();
for (Zombie z : new ArrayList<>(zombies.values())) {
if (!z.isAlive()) {
onZombieKilled(z);
zombies.remove(z.getId());
continue;
}
Player nearest = findNearestPlayer(z.getX(), z.getY());
if (nearest != null) {
if (z.getPath() == null || z.getPathIndex() >= (z.getPath() != null ? z.getPath().size() : 0)
|| random.nextFloat() < 0.02f) {
z.updatePath(map, nearest.getX(), nearest.getY());
}
if (z.isElite()) {
float dist = z.distanceTo(nearest.getX(), nearest.getY());
if (dist <= Constants.ELITE_ZOMBIE_ATTACK_RANGE && z.canRangedAttack(now)) {
fireZombieBullet(z, nearest);
z.rangedAttack(now);
}
}
}
z.move(map, dt);
}
}
private void fireZombieBullet(Zombie zombie, Player target) {
float dx = target.getX() - zombie.getX();
float dy = target.getY() - zombie.getY();
float angle = (float) Math.atan2(dx, dy);
float startX = zombie.getX() + (float) Math.sin(angle) * 0.5f;
float startY = zombie.getY() + (float) Math.cos(angle) * 0.5f;
Bullet bullet = new Bullet(nextZombieBulletId++, startX, startY, angle,
Constants.ELITE_ZOMBIE_BULLET_SPEED, Constants.ELITE_ZOMBIE_BULLET_DAMAGE,
"zombie_" + zombie.getId(), "zombie_bullet", 15);
zombieBullets.put(bullet.getId(), bullet);
}
private void updateZombieBullets(float dt) {
List<Integer> toRemove = new ArrayList<>();
for (Bullet b : zombieBullets.values()) {
if (!b.update(dt, map)) {
toRemove.add(b.getId());
}
}
for (int id : toRemove) {
zombieBullets.remove(id);
removedZombieBullets.add(id);
}
}
private void checkZombieBulletCollisions() {
List<Integer> bulletsToRemove = new ArrayList<>();
for (Bullet b : new ArrayList<>(zombieBullets.values())) {
for (Player p : new ArrayList<>(players.values())) {
if (!p.isAlive()) continue;
if (b.hitsEntity(p.getX(), p.getY(), Constants.PLAYER_SIZE)) {
p.takeDamage(b.getDamage());
bulletsToRemove.add(b.getId());
break;
}
}
}
for (int id : bulletsToRemove) {
zombieBullets.remove(id);
removedZombieBullets.add(id);
}
}
private void onZombieKilled(Zombie z) {
score += z.isElite() ? 50 : 10;
if (random.nextFloat() < Constants.ZOMBIE_LOOT_DROP_CHANCE) {
String lootType = random.nextFloat() < 0.5f ? Constants.LOOT_TYPE_AMMO : Constants.LOOT_TYPE_HEALTH;
Loot loot = new Loot(nextLootId++, z.getX(), z.getY(), lootType);
loots.put(loot.getId(), loot);
}
}
private void updateBullets(float dt) {
List<Integer> toRemove = new ArrayList<>();
for (Bullet b : bullets.values()) {
if (!b.update(dt, map)) {
if (b.isExplosive()) {
createExplosion(b.getX(), b.getY(), b.getExplosionRadius(), b.getOwnerId());
}
toRemove.add(b.getId());
}
}
for (int id : toRemove) {
bullets.remove(id);
removedBullets.add(id);
}
}
private void checkBulletCollisions() {
List<Integer> bulletsToRemove = new ArrayList<>();
for (Bullet b : new ArrayList<>(bullets.values())) {
if (b.isGrenade()) continue;
for (Zombie z : new ArrayList<>(zombies.values())) {
if (!z.isAlive()) continue;
if (b.hitsEntity(z.getX(), z.getY(), Constants.ZOMBIE_SIZE)) {
z.takeDamage(b.getDamage());
bulletsToRemove.add(b.getId());
break;
}
}
}
for (int id : bulletsToRemove) {
bullets.remove(id);
removedBullets.add(id);
}
}
private void createExplosion(float x, float y, float radius, String ownerId) {
Map<String, Object> exp = new LinkedHashMap<>();
exp.put("x", x);
exp.put("y", y);
exp.put("radius", radius);
explosions.add(exp);
for (Zombie z : new ArrayList<>(zombies.values())) {
float dist = z.distanceTo(x, y);
if (dist < radius) {
z.takeDamage(120);
}
}
}
private void checkZombieAttacks() {
long now = System.currentTimeMillis();
for (Zombie z : zombies.values()) {
if (!z.isAlive()) continue;
for (Player p : players.values()) {
if (!p.isAlive()) continue;
float dist = z.distanceTo(p.getX(), p.getY());
if (dist < 1.0f && z.canAttack(now)) {
p.takeDamage(Constants.ZOMBIE_DAMAGE);
z.attack(now);
}
}
}
}
private void checkLootCollection() {
List<Integer> toRemove = new ArrayList<>();
for (Loot loot : loots.values()) {
for (Player p : players.values()) {
if (!p.isAlive()) continue;
if (loot.isCollectedBy(p.getX(), p.getY())) {
if (loot.getType().equals(Constants.LOOT_TYPE_HEALTH)) {
p.heal(Constants.LOOT_HEALTH_AMOUNT);
} else {
p.refillRandomWeapon();
}
toRemove.add(loot.getId());
break;
}
}
}
for (int id : toRemove) {
loots.remove(id);
}
}
public List<Integer> fireWeapon(Player player, float aimX, float aimY) {
return fireWeapon(player, aimX, aimY, 0);
}
public List<Integer> fireWeapon(Player player, float aimX, float aimY, float chargePercent) {
List<Integer> newBulletIds = new ArrayList<>();
long now = System.currentTimeMillis();
if (!player.canFire(now) || !player.hasAmmo()) return newBulletIds;
player.setAngle(aimX, aimY);
player.fire(now);
String weapon;
switch (player.getWeaponIndex()) {
case 1: weapon = Constants.WEAPON_MACHINE_GUN; break;
case 2: weapon = Constants.WEAPON_SHOTGUN; break;
case 3: weapon = Constants.WEAPON_GRENADE; break;
default: weapon = Constants.WEAPON_PISTOL;
}
if (weapon.equals(Constants.WEAPON_GRENADE)) {
float startX = player.getX();
float startY = player.getY();
float minDist = 3.0f;
float maxDist = 15.0f;
float dist = minDist + (maxDist - minDist) * chargePercent;
float dx = aimX - startX;
float dy = aimY - startY;
float targetDist = (float) Math.sqrt(dx * dx + dy * dy);
float targetX, targetY;
if (targetDist < 0.1f) {
targetX = startX + minDist;
targetY = startY;
} else {
float scale = Math.min(dist, targetDist) / targetDist;
targetX = startX + dx * scale;
targetY = startY + dy * scale;
}
targetX = Math.max(0.5f, Math.min(Constants.GRID_SIZE - 0.5f, targetX));
targetY = Math.max(0.5f, Math.min(Constants.GRID_SIZE - 0.5f, targetY));
float flightDuration = 0.8f + chargePercent * 0.7f;
Bullet bullet = new Bullet(nextBulletId++, startX, startY, targetX, targetY,
flightDuration, player.getDamage(), player.getId(), 3.0f);
bullets.put(bullet.getId(), bullet);
newBulletIds.add(bullet.getId());
} else {
int pellets = player.getPelletCount();
float spread = player.getSpread();
for (int i = 0; i < pellets; i++) {
float angle = player.getAngle();
if (spread > 0) {
angle += (random.nextFloat() - 0.5f) * spread * 2;
}
float startX = player.getX() + (float) Math.sin(angle) * 0.5f;
float startY = player.getY() + (float) Math.cos(angle) * 0.5f;
float speed = player.getBulletSpeed();
int damage = player.getDamage();
float range;
switch (weapon) {
case Constants.WEAPON_MACHINE_GUN: range = 25; break;
case Constants.WEAPON_SHOTGUN: range = 12; break;
default: range = 30;
}
Bullet bullet = new Bullet(nextBulletId++, startX, startY, angle,
speed, damage, player.getId(), weapon, range);
bullets.put(bullet.getId(), bullet);
newBulletIds.add(bullet.getId());
}
}
return newBulletIds;
}
public int[][] getMapData() {
return map.getCells();
}
public Map<String, Object> buildGameState(String forPlayerId) {
Map<String, Object> state = new LinkedHashMap<>();
List<Map<String, Object>> playerStates = new ArrayList<>();
for (Player p : players.values()) {
playerStates.add(p.toStateMap());
}
state.put("players", playerStates);
List<Map<String, Object>> zombieStates = new ArrayList<>();
for (Zombie z : zombies.values()) {
zombieStates.add(z.toStateMap());
}
state.put("zombies", zombieStates);
List<Map<String, Object>> bulletStates = new ArrayList<>();
for (Bullet b : bullets.values()) {
bulletStates.add(b.toStateMap());
}
state.put("bullets", bulletStates);
List<Map<String, Object>> zombieBulletStates = new ArrayList<>();
for (Bullet b : zombieBullets.values()) {
zombieBulletStates.add(b.toStateMap());
}
state.put("zombieBullets", zombieBulletStates);
List<Map<String, Object>> lootStates = new ArrayList<>();
for (Loot l : loots.values()) {
lootStates.add(l.toStateMap());
}
state.put("loots", lootStates);
state.put("explosions", new ArrayList<>(explosions));
state.put("removedBullets", new ArrayList<>(removedBullets));
state.put("gameTime", gameTime);
state.put("waveNumber", waveNumber);
state.put("score", score);
Player forPlayer = players.get(forPlayerId);
if (forPlayer != null) {
Map<String, Object> ammoMap = new LinkedHashMap<>();
ammoMap.put(Constants.WEAPON_PISTOL, forPlayer.getAmmo()[0] == Integer.MAX_VALUE ? -1 : forPlayer.getAmmo()[0]);
ammoMap.put(Constants.WEAPON_MACHINE_GUN, (int) forPlayer.getAmmo()[1]);
ammoMap.put(Constants.WEAPON_SHOTGUN, (int) forPlayer.getAmmo()[2]);
ammoMap.put(Constants.WEAPON_GRENADE, (int) forPlayer.getAmmo()[3]);
state.put("ammo", ammoMap);
}
return state;
}
}

View File

@@ -0,0 +1,42 @@
package com.zombie.game.model;
import java.util.*;
public class Loot {
private int id;
private float x, y;
private String type;
private long spawnTime;
public Loot(int id, float x, float y) {
this(id, x, y, Constants.LOOT_TYPE_AMMO);
}
public Loot(int id, float x, float y, String type) {
this.id = id;
this.x = x;
this.y = y;
this.type = type;
this.spawnTime = System.currentTimeMillis();
}
public int getId() { return id; }
public float getX() { return x; }
public float getY() { return y; }
public String getType() { return type; }
public boolean isCollectedBy(float px, float py) {
float dx = px - x;
float dy = py - y;
return Math.sqrt(dx * dx + dy * dy) < 0.8f;
}
public Map<String, Object> toStateMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", id);
map.put("x", x);
map.put("y", y);
map.put("type", type);
return map;
}
}

View File

@@ -0,0 +1,203 @@
package com.zombie.game.model;
import java.util.*;
public class Player {
private String id;
private String name;
private float x, y;
private float angle;
private float health;
private int weaponIndex;
private boolean ready;
private long lastAttackTime;
private long lastDamageTime;
private float[] ammo;
private boolean firing;
private float grenadeChargeStart;
private boolean chargingGrenade;
private int lastProcessedSeq;
private static final String[] WEAPONS = {
Constants.WEAPON_PISTOL, Constants.WEAPON_MACHINE_GUN,
Constants.WEAPON_SHOTGUN, Constants.WEAPON_GRENADE
};
private static final int[] MAX_AMMO = {Integer.MAX_VALUE, 100, 20, 10};
public Player(String id, String name, float x, float y) {
this.id = id;
this.name = name;
this.x = x;
this.y = y;
this.angle = 0;
this.health = Constants.PLAYER_MAX_HEALTH;
this.weaponIndex = 0;
this.ready = false;
this.lastAttackTime = 0;
this.lastDamageTime = 0;
this.ammo = new float[]{Integer.MAX_VALUE, 100, 20, 10};
this.firing = false;
this.grenadeChargeStart = 0;
this.chargingGrenade = false;
this.lastProcessedSeq = 0;
}
public String getId() { return id; }
public String getName() { return name; }
public float getX() { return x; }
public float getY() { return y; }
public float getAngle() { return angle; }
public float getHealth() { return health; }
public int getWeaponIndex() { return weaponIndex; }
public boolean isReady() { return ready; }
public float[] getAmmo() { return ammo; }
public boolean isFiring() { return firing; }
public int getLastProcessedSeq() { return lastProcessedSeq; }
public void setReady(boolean ready) { this.ready = ready; }
public void setWeaponIndex(int idx) { this.weaponIndex = Math.max(0, Math.min(3, idx)); }
public void applyMovement(float dx, float dy, GameMap map) {
float speed = Constants.PLAYER_SPEED * Constants.TICK_INTERVAL;
float newX = x + dx * speed;
float newY = y + dy * speed;
if (map.isWalkable(newX, y, Constants.PLAYER_SIZE)) {
x = Math.max(0.5f, Math.min(Constants.GRID_SIZE - 0.5f, newX));
}
if (map.isWalkable(x, newY, Constants.PLAYER_SIZE)) {
y = Math.max(0.5f, Math.min(Constants.GRID_SIZE - 0.5f, newY));
}
}
public void setAngle(float aimX, float aimY) {
this.angle = (float) Math.atan2(aimX - x, aimY - y);
}
public void takeDamage(float damage) {
long now = System.currentTimeMillis();
if (now - lastDamageTime < Constants.PLAYER_INVULNERABLE_TIME * 1000) return;
this.health -= damage;
this.lastDamageTime = now;
if (this.health < 0) this.health = 0;
}
public boolean isAlive() {
return health > 0;
}
public float distanceTo(float px, float py) {
float dx = px - x;
float dy = py - y;
return (float) Math.sqrt(dx * dx + dy * dy);
}
public boolean canFire(long now) {
String weapon = WEAPONS[weaponIndex];
long fireRate = getFireRate(weapon);
return now - lastAttackTime >= fireRate;
}
public void fire(long now) {
lastAttackTime = now;
String weapon = WEAPONS[weaponIndex];
int idx = weaponIndex;
if (idx != 0 && ammo[idx] > 0) {
ammo[idx]--;
}
}
public boolean hasAmmo() {
if (weaponIndex == 0) return true;
return ammo[weaponIndex] > 0;
}
public void refillRandomWeapon() {
Random rand = new Random();
int idx = rand.nextInt(3) + 1;
ammo[idx] = MAX_AMMO[idx];
}
public void heal(float amount) {
this.health = Math.min(Constants.PLAYER_MAX_HEALTH, this.health + amount);
}
public void setFiring(boolean firing) { this.firing = firing; }
public void setLastProcessedSeq(int seq) { this.lastProcessedSeq = seq; }
public void startGrenadeCharge() {
if (!chargingGrenade) {
chargingGrenade = true;
grenadeChargeStart = System.currentTimeMillis();
}
}
public float getGrenadeChargePercent() {
if (!chargingGrenade) return 0;
float elapsed = (System.currentTimeMillis() - grenadeChargeStart) / 2000f;
return Math.min(1.0f, elapsed);
}
public void stopGrenadeCharge() {
chargingGrenade = false;
}
private long getFireRate(String weapon) {
switch (weapon) {
case Constants.WEAPON_PISTOL: return 400;
case Constants.WEAPON_MACHINE_GUN: return 100;
case Constants.WEAPON_SHOTGUN: return 800;
case Constants.WEAPON_GRENADE: return 1500;
default: return 400;
}
}
public int getDamage() {
switch (WEAPONS[weaponIndex]) {
case Constants.WEAPON_PISTOL: return 50;
case Constants.WEAPON_MACHINE_GUN: return 50;
case Constants.WEAPON_SHOTGUN: return 50;
case Constants.WEAPON_GRENADE: return 120;
default: return 50;
}
}
public float getBulletSpeed() {
switch (WEAPONS[weaponIndex]) {
case Constants.WEAPON_PISTOL: return 20;
case Constants.WEAPON_MACHINE_GUN: return 25;
case Constants.WEAPON_SHOTGUN: return 18;
case Constants.WEAPON_GRENADE: return 12;
default: return 20;
}
}
public int getPelletCount() {
return WEAPONS[weaponIndex].equals(Constants.WEAPON_SHOTGUN) ? 10 : 1;
}
public float getSpread() {
switch (WEAPONS[weaponIndex]) {
case Constants.WEAPON_MACHINE_GUN: return 0.05f;
case Constants.WEAPON_SHOTGUN: return 0.15f;
default: return 0;
}
}
public boolean isChargeable() {
return WEAPONS[weaponIndex].equals(Constants.WEAPON_GRENADE);
}
public Map<String, Object> toStateMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", id);
map.put("x", x);
map.put("y", y);
map.put("angle", angle);
map.put("health", health);
map.put("weaponIndex", weaponIndex);
map.put("lastProcessedSeq", lastProcessedSeq);
return map;
}
}

View File

@@ -0,0 +1,88 @@
package com.zombie.game.model;
import java.util.*;
public class Room {
private String id;
private String hostId;
private Map<String, Player> players;
private boolean gameStarted;
private int maxPlayers = 4;
public Room(String id, String hostId, String hostName) {
this.id = id;
this.hostId = hostId;
this.players = new LinkedHashMap<>();
Player host = new Player(hostId, hostName, 0, 0);
players.put(hostId, host);
}
public String getId() { return id; }
public String getHostId() { return hostId; }
public boolean isGameStarted() { return gameStarted; }
public void setGameStarted(boolean started) { this.gameStarted = started; }
public boolean addPlayer(String playerId, String playerName) {
if (players.size() >= maxPlayers) return false;
if (players.containsKey(playerId)) return false;
Player player = new Player(playerId, playerName, 0, 0);
players.put(playerId, player);
return true;
}
public void removePlayer(String playerId) {
players.remove(playerId);
}
public Player getPlayer(String playerId) {
return players.get(playerId);
}
public Collection<Player> getPlayers() {
return players.values();
}
public int getPlayerCount() {
return players.size();
}
public boolean isHost(String playerId) {
return hostId.equals(playerId);
}
public boolean allReady() {
for (Player p : players.values()) {
if (!p.getId().equals(hostId) && !p.isReady()) return false;
}
return true;
}
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);
List<Map<String, Object>> playerList = new ArrayList<>();
int index = 0;
for (Player 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;
}
public Map<String, Object> toRoomListMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", id);
map.put("hostName", players.get(hostId) != null ? players.get(hostId).getName() : "Unknown");
map.put("playerCount", players.size());
return map;
}
}

View File

@@ -0,0 +1,129 @@
package com.zombie.game.model;
import java.util.*;
public class Zombie {
private int id;
private float x, y;
private float angle;
private float health;
private float maxHealth;
private float speed;
private long lastAttackTime;
private List<float[]> path;
private int pathIndex;
private float targetX, targetY;
private boolean isElite;
private long lastRangedAttackTime;
public Zombie(int id, float x, float y, float health, float speed) {
this(id, x, y, health, speed, false);
}
public Zombie(int id, float x, float y, float health, float speed, boolean isElite) {
this.id = id;
this.x = x;
this.y = y;
this.angle = 0;
this.health = health;
this.maxHealth = health;
this.speed = speed;
this.lastAttackTime = 0;
this.path = null;
this.pathIndex = 0;
this.isElite = isElite;
this.lastRangedAttackTime = 0;
}
public int getId() { return id; }
public float getX() { return x; }
public float getY() { return y; }
public float getAngle() { return angle; }
public float getHealth() { return health; }
public float getMaxHealth() { return maxHealth; }
public List<float[]> getPath() { return path; }
public int getPathIndex() { return pathIndex; }
public boolean isElite() { return isElite; }
public void takeDamage(float damage) {
this.health -= damage;
if (this.health < 0) this.health = 0;
}
public boolean isAlive() {
return health > 0;
}
public void updatePath(GameMap map, float targetX, float targetY) {
this.targetX = targetX;
this.targetY = targetY;
this.path = map.findPath(x, y, targetX, targetY);
this.pathIndex = 0;
}
public void move(GameMap map, float dt) {
if (path != null && pathIndex < path.size()) {
float[] target = path.get(pathIndex);
float dx = target[0] - x;
float dy = target[1] - y;
float dist = (float) Math.sqrt(dx * dx + dy * dy);
if (dist < 0.2f) {
pathIndex++;
if (pathIndex >= path.size()) {
path = null;
}
return;
}
float moveX = (dx / dist) * speed * dt;
float moveY = (dy / dist) * speed * dt;
float newX = x + moveX;
float newY = y + moveY;
if (map.isWalkable(newX, y, Constants.ZOMBIE_SIZE)) {
x = newX;
}
if (map.isWalkable(x, newY, Constants.ZOMBIE_SIZE)) {
y = newY;
}
angle = (float) Math.atan2(dx, dy);
}
}
public boolean canAttack(long now) {
return now - lastAttackTime >= Constants.ZOMBIE_ATTACK_RATE * 1000;
}
public void attack(long now) {
lastAttackTime = now;
}
public boolean canRangedAttack(long now) {
if (!isElite) return false;
return now - lastRangedAttackTime >= Constants.ELITE_ZOMBIE_ATTACK_RATE * 1000;
}
public void rangedAttack(long now) {
lastRangedAttackTime = now;
}
public float distanceTo(float px, float py) {
float dx = px - x;
float dy = py - y;
return (float) Math.sqrt(dx * dx + dy * dy);
}
public Map<String, Object> toStateMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", id);
map.put("x", x);
map.put("y", y);
map.put("angle", angle);
map.put("health", health);
map.put("isElite", isElite);
return map;
}
}

View File

@@ -0,0 +1,46 @@
package com.zombie.game.server;
import com.zombie.game.model.GameWorld;
public class GameLoop implements Runnable {
private String roomId;
private GameWorld world;
private GameWebSocketServer server;
private volatile boolean running;
private static final int TICK_RATE = 30;
private static final long TICK_INTERVAL = 1000 / TICK_RATE;
public GameLoop(String roomId, GameWorld world, GameWebSocketServer server) {
this.roomId = roomId;
this.world = world;
this.server = server;
this.running = true;
}
@Override
public void run() {
long lastTime = System.currentTimeMillis();
while (running) {
long now = System.currentTimeMillis();
long delta = now - lastTime;
if (delta >= TICK_INTERVAL) {
float dt = delta / 1000.0f;
world.update(dt);
server.broadcastGameState(roomId, world);
lastTime = now;
} else {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
break;
}
}
}
}
public void stop() {
running = false;
}
}

View File

@@ -0,0 +1,373 @@
package com.zombie.game.server;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.zombie.game.model.*;
import org.java_websocket.WebSocket;
import org.java_websocket.handshake.ClientHandshake;
import org.java_websocket.server.WebSocketServer;
import java.net.InetSocketAddress;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class GameWebSocketServer extends WebSocketServer {
private Gson gson;
private Map<String, Room> rooms;
private Map<WebSocket, String> connectionToPlayer;
private Map<String, WebSocket> playerToConnection;
private Map<String, GameWorld> activeGames;
private Map<String, GameLoop> gameLoops;
private Timer roomListTimer;
public GameWebSocketServer(int port) {
super(new InetSocketAddress(port));
this.gson = new Gson();
this.rooms = new ConcurrentHashMap<>();
this.connectionToPlayer = new ConcurrentHashMap<>();
this.playerToConnection = new ConcurrentHashMap<>();
this.activeGames = new ConcurrentHashMap<>();
this.gameLoops = new ConcurrentHashMap<>();
}
@Override
public void onOpen(WebSocket conn, ClientHandshake handshake) {
System.out.println("New connection: " + conn.getRemoteSocketAddress());
}
@Override
public void onClose(WebSocket conn, int code, String reason, boolean remote) {
System.out.println("Connection closed: " + conn.getRemoteSocketAddress());
String playerId = connectionToPlayer.remove(conn);
if (playerId != null) {
playerToConnection.remove(playerId);
handleLeaveRoomByPlayerId(playerId);
}
}
@Override
public void onMessage(WebSocket conn, String message) {
try {
JsonObject msg = gson.fromJson(message, JsonObject.class);
String type = msg.get("type").getAsString();
JsonObject data = msg.has("data") ? msg.getAsJsonObject("data") : new JsonObject();
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) {
e.printStackTrace();
sendError(conn, "Invalid message format");
}
}
@Override
public void onError(WebSocket conn, Exception ex) {
ex.printStackTrace();
}
@Override
public void onStart() {
System.out.println("Game WebSocket Server started on port " + getPort());
roomListTimer = new Timer(true);
roomListTimer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
broadcastRoomList();
}
}, 0, 2000);
}
private void handleCreateRoom(WebSocket conn, JsonObject data) {
String playerName = data.get("playerName").getAsString();
String playerId = UUID.randomUUID().toString();
String roomId = UUID.randomUUID().toString().substring(0, 8);
connectionToPlayer.put(conn, playerId);
playerToConnection.put(playerId, conn);
Room room = new Room(roomId, playerId, playerName);
rooms.put(roomId, room);
sendToConnection(conn, Constants.MSG_ROOM_STATE, room.toStateMap(playerId));
System.out.println("Room created: " + roomId + " by " + playerName);
}
private void handleJoinRoom(WebSocket conn, JsonObject data) {
String roomId = data.get("roomId").getAsString();
String playerName = data.get("playerName").getAsString();
Room room = rooms.get(roomId);
if (room == null) {
sendError(conn, "Room not found");
return;
}
if (room.getPlayerCount() >= 4) {
sendError(conn, "Room is full");
return;
}
String playerId = UUID.randomUUID().toString();
connectionToPlayer.put(conn, playerId);
playerToConnection.put(playerId, conn);
room.addPlayer(playerId, playerName);
broadcastRoomState(room);
System.out.println("Player " + playerName + " joined room " + roomId);
}
private void handleLeaveRoomByConn(WebSocket conn) {
String playerId = connectionToPlayer.get(conn);
if (playerId == null) return;
handleLeaveRoomByPlayerId(playerId);
}
private void handleLeaveRoomByPlayerId(String playerId) {
for (Room room : new ArrayList<>(rooms.values())) {
if (room.getPlayer(playerId) != null) {
room.removePlayer(playerId);
if (room.getPlayerCount() == 0) {
stopGame(room.getId());
rooms.remove(room.getId());
} else {
broadcastRoomState(room);
}
break;
}
}
}
private void handleRoomList(WebSocket conn) {
List<Map<String, Object>> roomList = new ArrayList<>();
for (Room room : rooms.values()) {
if (!room.isGameStarted()) {
roomList.add(room.toRoomListMap());
}
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("rooms", roomList);
sendToConnection(conn, Constants.MSG_ROOM_LIST, data);
}
private void handleReady(WebSocket conn) {
String playerId = connectionToPlayer.get(conn);
if (playerId == null) return;
for (Room room : rooms.values()) {
Player player = room.getPlayer(playerId);
if (player != null) {
player.setReady(!player.isReady());
broadcastRoomState(room);
break;
}
}
}
private void handleStartGame(WebSocket conn) {
String playerId = connectionToPlayer.get(conn);
if (playerId == null) return;
for (Room room : rooms.values()) {
if (room.isHost(playerId) && !room.isGameStarted() && room.allReady()) {
room.setGameStarted(true);
startGame(room);
break;
}
}
}
private void startGame(Room room) {
GameWorld world = new GameWorld();
int index = 0;
List<int[]> spawnPoints = world.getMap().getSpawnPoints();
for (Player player : room.getPlayers()) {
int[] sp = spawnPoints.get(index % spawnPoints.size());
float wx = sp[0] + 0.5f;
float wy = sp[1] + 0.5f;
try {
java.lang.reflect.Field xField = Player.class.getDeclaredField("x");
xField.setAccessible(true);
xField.setFloat(player, wx);
java.lang.reflect.Field yField = Player.class.getDeclaredField("y");
yField.setAccessible(true);
yField.setFloat(player, wy);
} catch (Exception e) {
e.printStackTrace();
}
world.addPlayer(player);
index++;
}
activeGames.put(room.getId(), world);
for (Player player : room.getPlayers()) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("playerId", player.getId());
data.put("mapData", serializeMapData(world.getMapData()));
List<Map<String, Object>> playerList = new ArrayList<>();
int idx = 0;
for (Player p : room.getPlayers()) {
Map<String, Object> pm = new LinkedHashMap<>();
pm.put("id", p.getId());
pm.put("name", p.getName());
pm.put("x", p.getX());
pm.put("y", p.getY());
pm.put("index", idx++);
playerList.add(pm);
}
data.put("players", playerList);
WebSocket pConn = playerToConnection.get(player.getId());
if (pConn != null) {
sendToConnection(pConn, Constants.MSG_GAME_STARTED, data);
}
}
GameLoop loop = new GameLoop(room.getId(), world, this);
gameLoops.put(room.getId(), loop);
new Thread(loop).start();
System.out.println("Game started for room: " + room.getId());
}
private void stopGame(String roomId) {
GameLoop loop = gameLoops.remove(roomId);
if (loop != null) {
loop.stop();
}
activeGames.remove(roomId);
}
private void handlePlayerInput(WebSocket conn, JsonObject data) {
String playerId = connectionToPlayer.get(conn);
if (playerId == null) return;
for (Room room : rooms.values()) {
if (room.isGameStarted()) {
GameWorld world = activeGames.get(room.getId());
if (world == null) continue;
Player player = world.getPlayer(playerId);
if (player == null) continue;
float dx = data.has("dx") ? data.get("dx").getAsFloat() : 0;
float dy = data.has("dy") ? data.get("dy").getAsFloat() : 0;
float aimX = data.has("aimX") ? data.get("aimX").getAsFloat() : 0;
float aimY = data.has("aimY") ? data.get("aimY").getAsFloat() : 0;
boolean firing = data.has("firing") && data.get("firing").getAsBoolean();
int weaponIndex = data.has("weaponIndex") ? data.get("weaponIndex").getAsInt() : -1;
int seq = data.has("seq") ? data.get("seq").getAsInt() : 0;
float grenadeCharge = data.has("grenadeCharge") ? data.get("grenadeCharge").getAsFloat() : 0;
boolean grenadeReleased = data.has("grenadeReleased") && data.get("grenadeReleased").getAsBoolean();
if (weaponIndex >= 0 && weaponIndex <= 3) {
player.setWeaponIndex(weaponIndex);
}
player.applyMovement(dx, dy, world.getMap());
player.setAngle(aimX, aimY);
player.setLastProcessedSeq(seq);
if (grenadeReleased && player.hasAmmo() && player.getWeaponIndex() == 3) {
world.fireWeapon(player, aimX, aimY, grenadeCharge);
} else if (firing && player.hasAmmo() && player.getWeaponIndex() != 3) {
world.fireWeapon(player, aimX, aimY);
}
break;
}
}
}
public void broadcastGameState(String roomId, GameWorld world) {
Room room = rooms.get(roomId);
if (room == null) return;
for (Player player : room.getPlayers()) {
Map<String, Object> state = world.buildGameState(player.getId());
WebSocket pConn = playerToConnection.get(player.getId());
if (pConn != null && pConn.isOpen()) {
sendToConnection(pConn, Constants.MSG_GAME_STATE, state);
}
}
}
private void broadcastRoomState(Room room) {
for (Player player : room.getPlayers()) {
WebSocket pConn = playerToConnection.get(player.getId());
if (pConn != null && pConn.isOpen()) {
sendToConnection(pConn, Constants.MSG_ROOM_STATE, room.toStateMap(player.getId()));
}
}
}
private void broadcastRoomList() {
List<Map<String, Object>> roomList = new ArrayList<>();
for (Room room : rooms.values()) {
if (!room.isGameStarted()) {
roomList.add(room.toRoomListMap());
}
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("rooms", roomList);
for (WebSocket conn : getConnections()) {
if (connectionToPlayer.get(conn) == null) {
sendToConnection(conn, Constants.MSG_ROOM_LIST, data);
}
}
}
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;
}
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));
}
}
private void sendError(WebSocket conn, String message) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("message", message);
sendToConnection(conn, Constants.MSG_ERROR, data);
}
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zombie Crisis 3 - Multiplayer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1113
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "zombie-crisis-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.170.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

470
frontend/src/game/engine.js Normal file
View File

@@ -0,0 +1,470 @@
import { TICK_RATE, TICK_INTERVAL, MSG_TYPE, WEAPONS, WEAPON_CONFIG, PLAYER_CONFIG, ZOMBIE_CONFIG } from '../utils/constants.js'
import { Grid, generateDefaultMap } from '../utils/grid.js'
import { InputManager } from '../utils/input.js'
import { GameScene } from './scene.js'
import { NetworkClient } from '../network/client.js'
export class GameEngine {
constructor(canvas) {
this.canvas = canvas
this.scene = new GameScene(canvas)
this.input = new InputManager()
this.network = new NetworkClient()
this.grid = null
this.mapData = null
this.localPlayerId = null
this.players = new Map()
this.zombies = new Map()
this.bullets = new Map()
this.zombieBullets = new Map()
this.loots = new Map()
this.running = false
this.lastTick = 0
this.accumulator = 0
this.pendingInputs = []
this.serverStates = []
this.currentWeaponIndex = 0
this.weaponAmmo = {
[WEAPONS.PISTOL]: Infinity,
[WEAPONS.MACHINE_GUN]: 100,
[WEAPONS.SHOTGUN]: 20,
[WEAPONS.GRENADE]: 10
}
this.grenadeChargeStart = 0
this.isChargingGrenade = false
this.grenadeChargePercent = 0
this.grenadeReleased = false
this.gameTime = 0
this.waveNumber = 0
this.score = 0
this.onStateUpdate = null
}
async connect(url) {
await this.network.connect(url)
this._setupNetworkHandlers()
}
_setupNetworkHandlers() {
this.network.on(MSG_TYPE.GAME_STARTED, (data) => {
this.localPlayerId = data.playerId
this.mapData = data.mapData || generateDefaultMap()
this.grid = new Grid(this.mapData)
this.scene.buildMap(this.mapData)
this._initPlayers(data.players)
this.start()
if (this.onStateUpdate) this.onStateUpdate('game_started', data)
})
this.network.on(MSG_TYPE.GAME_STATE, (data) => {
this._processServerState(data)
})
this.network.on(MSG_TYPE.PLAYER_JOIN, (data) => {
this._addPlayer(data)
})
this.network.on(MSG_TYPE.PLAYER_LEAVE, (data) => {
this._removePlayer(data)
})
this.network.on(MSG_TYPE.ERROR, (data) => {
console.error('Server error:', data.message)
})
}
_initPlayers(playersData) {
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
for (const p of playersData) {
const isLocal = p.id === this.localPlayerId
const color = colors[p.index % colors.length]
this.players.set(p.id, {
id: p.id,
name: p.name,
x: p.x,
y: p.y,
angle: 0,
health: PLAYER_CONFIG.MAX_HEALTH,
weaponIndex: 0,
color,
isLocal
})
this.scene.addPlayer(p.id, p.x, p.y, color, isLocal)
}
}
_addPlayer(data) {
const colors = [0x4488ff, 0xff4444, 0x44ff44, 0xffff44]
const isLocal = data.id === this.localPlayerId
const color = colors[data.index % colors.length]
this.players.set(data.id, {
id: data.id,
name: data.name,
x: data.x,
y: data.y,
angle: 0,
health: PLAYER_CONFIG.MAX_HEALTH,
weaponIndex: 0,
color,
isLocal
})
this.scene.addPlayer(data.id, data.x, data.y, color, isLocal)
}
_removePlayer(data) {
this.players.delete(data.id)
this.scene.removePlayer(data.id)
}
start() {
if (this.running) return
this.running = true
this.input.attach()
this.lastTick = performance.now()
this._loop()
}
stop() {
this.running = false
this.input.detach()
}
_loop() {
if (!this.running) return
requestAnimationFrame(() => this._loop())
const now = performance.now()
const delta = now - this.lastTick
this.lastTick = now
this.accumulator += delta
while (this.accumulator >= TICK_INTERVAL) {
this._tick()
this.accumulator -= TICK_INTERVAL
}
const localPlayer = this.players.get(this.localPlayerId)
if (localPlayer) {
this.scene.updateCamera(localPlayer.x, localPlayer.y)
if (this.isChargingGrenade && this.currentWeaponIndex === 3) {
this.scene.showGrenadeTarget(localPlayer.x, localPlayer.y,
this.input.mouse.groundX || 0, this.input.mouse.groundY || 0,
this.grenadeChargePercent)
} else {
this.scene.hideGrenadeTarget()
}
}
this.scene.render()
}
_tick() {
if (!this.localPlayerId) return
const mouseGroundPos = this.scene.getMouseGroundPos(this.input.mouse.x, this.input.mouse.y)
this.input.mouse.groundX = mouseGroundPos.x
this.input.mouse.groundY = mouseGroundPos.y
const inputState = this.input.buildInputState(mouseGroundPos)
const weaponIdx = inputState.weaponIndex
if (weaponIdx >= 0 && weaponIdx !== this.currentWeaponIndex) {
this.currentWeaponIndex = weaponIdx
}
this._handleGrenadeCharge(inputState)
this._applyLocalPrediction(inputState)
inputState.grenadeCharge = this.grenadeChargePercent
inputState.grenadeReleased = this.grenadeReleased
inputState.firing = this.currentWeaponIndex === 3 ? false : inputState.firing
this.pendingInputs.push(inputState)
if (this.pendingInputs.length > 60) {
this.pendingInputs.splice(0, this.pendingInputs.length - 60)
}
this.network.sendInput(inputState)
if (this.grenadeReleased) {
this.grenadeReleased = false
this.grenadeChargePercent = 0
}
}
_handleGrenadeCharge(inputState) {
const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE]
const currentWeapon = weaponList[this.currentWeaponIndex]
if (currentWeapon === WEAPONS.GRENADE && WEAPON_CONFIG[WEAPONS.GRENADE].chargeable) {
if (inputState.firing && !this.isChargingGrenade) {
this.isChargingGrenade = true
this.grenadeChargeStart = Date.now()
} else if (inputState.firing && this.isChargingGrenade) {
const elapsed = Date.now() - this.grenadeChargeStart
this.grenadeChargePercent = Math.min(1, elapsed / WEAPON_CONFIG[WEAPONS.GRENADE].maxCharge)
} else if (!inputState.firing && this.isChargingGrenade) {
this.grenadeReleased = true
this.isChargingGrenade = false
const elapsed = Date.now() - this.grenadeChargeStart
this.grenadeChargePercent = Math.min(1, elapsed / WEAPON_CONFIG[WEAPONS.GRENADE].maxCharge)
}
} else {
this.isChargingGrenade = false
this.grenadeChargePercent = 0
}
}
_applyLocalPrediction(inputState) {
const player = this.players.get(this.localPlayerId)
if (!player) return
const speed = PLAYER_CONFIG.SPEED
const dt = TICK_INTERVAL / 1000
let newX = player.x + inputState.dx * speed * dt
let newY = player.y + inputState.dy * speed * dt
if (this.grid) {
if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x
if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y
}
newX = Math.max(0.5, Math.min(31.5, newX))
newY = Math.max(0.5, Math.min(31.5, newY))
player.x = newX
player.y = newY
const dx = inputState.aimX - player.x
const dy = inputState.aimY - player.y
player.angle = Math.atan2(dx, dy)
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
}
_processServerState(state) {
if (state.players) {
for (const ps of state.players) {
const player = this.players.get(ps.id)
if (player) {
if (ps.id === this.localPlayerId) {
this._reconcileLocalPlayer(ps)
} else {
player.x = ps.x
player.y = ps.y
player.angle = ps.angle
player.health = ps.health
player.weaponIndex = ps.weaponIndex || 0
this.scene.updatePlayer(ps.id, ps.x, ps.y, ps.angle, ps.health)
}
}
}
}
if (state.zombies) {
const serverZombieIds = new Set()
for (const zs of state.zombies) {
serverZombieIds.add(zs.id)
if (this.zombies.has(zs.id)) {
const zombie = this.zombies.get(zs.id)
const prevHealth = zombie.health
zombie.x = zs.x
zombie.y = zs.y
zombie.health = zs.health
const angle = zs.angle || 0
this.scene.updateZombie(zs.id, zs.x, zs.y, angle, zs.health)
if (prevHealth > zs.health && zs.health > 0) {
this.scene.addHitEffect(zs.x, zs.y)
}
} else {
const isElite = zs.isElite || false
this.zombies.set(zs.id, { id: zs.id, x: zs.x, y: zs.y, health: zs.health, angle: zs.angle || 0, isElite })
this.scene.addZombie(zs.id, zs.x, zs.y, isElite)
}
}
for (const [id, zombie] of this.zombies) {
if (!serverZombieIds.has(id)) {
this.scene.addHitEffect(zombie.x, zombie.y)
this.zombies.delete(id)
this.scene.removeZombie(id)
}
}
}
if (state.bullets) {
for (const bs of state.bullets) {
if (!this.bullets.has(bs.id)) {
this.bullets.set(bs.id, { ...bs })
this.scene.addBullet(bs)
const player = this.players.get(bs.ownerId)
if (player && bs.weapon !== WEAPONS.GRENADE) {
this.scene.addMuzzleFlash(player.x, player.y, bs.angle || 0)
}
} else {
const bullet = this.bullets.get(bs.id)
bullet.x = bs.x
bullet.y = bs.y
bullet.z = bs.z
this.scene.updateBullet(bs.id, bs.x, bs.y, bs.z)
}
}
const serverBulletIds = new Set(state.bullets.map(b => b.id))
for (const [id, bullet] of this.bullets) {
if (!serverBulletIds.has(id)) {
this._checkBulletHit(bullet)
this.bullets.delete(id)
this.scene.removeBullet(id)
}
}
}
if (state.removedBullets) {
for (const id of state.removedBullets) {
if (this.bullets.has(id)) {
const bullet = this.bullets.get(id)
this._checkBulletHit(bullet)
this.bullets.delete(id)
}
this.scene.removeBullet(id)
}
}
if (state.explosions) {
console.log('Explosions received:', state.explosions)
for (const exp of state.explosions) {
console.log('Creating explosion at:', exp.x, exp.y, 'radius:', exp.radius)
this.scene.addExplosion(exp.x, exp.y, exp.radius || 3)
}
}
if (state.hits) {
for (const hit of state.hits) {
this.scene.addHitEffect(hit.x, hit.y)
}
}
if (state.zombieBullets) {
for (const bs of state.zombieBullets) {
if (!this.zombieBullets.has(bs.id)) {
this.zombieBullets.set(bs.id, { ...bs })
this.scene.addZombieBullet(bs)
} else {
const bullet = this.zombieBullets.get(bs.id)
bullet.x = bs.x
bullet.y = bs.y
this.scene.updateZombieBullet(bs.id, bs.x, bs.y)
}
}
const serverBulletIds = new Set(state.zombieBullets.map(b => b.id))
for (const [id] of this.zombieBullets) {
if (!serverBulletIds.has(id)) {
this.zombieBullets.delete(id)
this.scene.removeZombieBullet(id)
}
}
}
if (state.removedZombieBullets) {
for (const id of state.removedZombieBullets) {
this.zombieBullets.delete(id)
this.scene.removeZombieBullet(id)
}
}
if (state.loots) {
for (const ls of state.loots) {
if (!this.loots.has(ls.id)) {
this.loots.set(ls.id, ls)
this.scene.addLoot(ls.id, ls.x, ls.y, ls.type || 'ammo')
}
}
const serverLootIds = new Set(state.loots.map(l => l.id))
for (const [id] of this.loots) {
if (!serverLootIds.has(id)) {
this.loots.delete(id)
this.scene.removeLoot(id)
}
}
}
if (state.ammo) {
for (const [weapon, ammo] of Object.entries(state.ammo)) {
this.weaponAmmo[weapon] = ammo
}
}
if (state.gameTime !== undefined) this.gameTime = state.gameTime
if (state.waveNumber !== undefined) this.waveNumber = state.waveNumber
if (state.score !== undefined) this.score = state.score
if (this.onStateUpdate) this.onStateUpdate('state', state)
}
_checkBulletHit(bullet) {
if (!bullet) return
for (const [, zombie] of this.zombies) {
const dx = bullet.x - zombie.x
const dy = bullet.y - zombie.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 1.0) {
this.scene.addHitEffect(zombie.x, zombie.y)
break
}
}
}
_reconcileLocalPlayer(serverState) {
const player = this.players.get(this.localPlayerId)
if (!player) return
let lastProcessedSeq = serverState.lastProcessedSeq || 0
player.x = serverState.x
player.y = serverState.y
player.angle = serverState.angle
player.health = serverState.health
this.pendingInputs = this.pendingInputs.filter(input => input.seq > lastProcessedSeq)
const speed = PLAYER_CONFIG.SPEED
const dt = TICK_INTERVAL / 1000
for (const input of this.pendingInputs) {
let newX = player.x + input.dx * speed * dt
let newY = player.y + input.dy * speed * dt
if (this.grid) {
if (!this.grid.isWalkable(newX, player.y, 0.8)) newX = player.x
if (!this.grid.isWalkable(player.x, newY, 0.8)) newY = player.y
}
newX = Math.max(0.5, Math.min(31.5, newX))
newY = Math.max(0.5, Math.min(31.5, newY))
player.x = newX
player.y = newY
const dx = input.aimX - player.x
const dy = input.aimY - player.y
player.angle = Math.atan2(dx, dy)
}
this.scene.updatePlayer(player.id, player.x, player.y, player.angle, player.health)
}
getGrenadeChargePercent() {
return this.grenadeChargePercent
}
destroy() {
this.stop()
this.network.disconnect()
this.scene.destroy()
}
}

798
frontend/src/game/scene.js Normal file
View File

@@ -0,0 +1,798 @@
import * as THREE from 'three'
import { GRID_SIZE, PLAYER_SIZE, ZOMBIE_SIZE, WEAPONS, WEAPON_CONFIG } from '../utils/constants.js'
export class GameScene {
constructor(canvas) {
this.canvas = canvas
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(0x1a1a2e)
this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 200)
this.cameraOffset = new THREE.Vector3(0, 25, 18)
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
this.renderer.setSize(window.innerWidth, window.innerHeight)
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
this.players = new Map()
this.zombies = new Map()
this.bullets = []
this.zombieBullets = []
this.loots = new Map()
this.effects = []
this.wallMeshes = []
this.gridHelper = null
this.playerMesh = null
this.bulletTrails = []
this.grenadeTargetGroup = null
this._setupLighting()
this._setupResize()
this._setupGrenadeTarget()
}
_setupLighting() {
const ambient = new THREE.AmbientLight(0x404060, 0.6)
this.scene.add(ambient)
const dirLight = new THREE.DirectionalLight(0xffeedd, 0.8)
dirLight.position.set(16, 30, 16)
dirLight.castShadow = true
dirLight.shadow.mapSize.width = 2048
dirLight.shadow.mapSize.height = 2048
dirLight.shadow.camera.near = 0.5
dirLight.shadow.camera.far = 80
dirLight.shadow.camera.left = -20
dirLight.shadow.camera.right = 20
dirLight.shadow.camera.top = 20
dirLight.shadow.camera.bottom = -20
this.scene.add(dirLight)
const pointLight = new THREE.PointLight(0xff4400, 0.3, 50)
pointLight.position.set(16, 10, 16)
this.scene.add(pointLight)
}
_setupResize() {
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight
this.camera.updateProjectionMatrix()
this.renderer.setSize(window.innerWidth, window.innerHeight)
})
}
_setupGrenadeTarget() {
this.grenadeTargetGroup = new THREE.Group()
this.grenadeTargetGroup.visible = false
const ringGeo = new THREE.RingGeometry(2.8, 3.0, 32)
const ringMat = new THREE.MeshBasicMaterial({
color: 0xff4400,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide
})
const ring = new THREE.Mesh(ringGeo, ringMat)
ring.rotation.x = -Math.PI / 2
ring.position.y = 0.05
this.grenadeTargetGroup.add(ring)
const innerRingGeo = new THREE.RingGeometry(0.8, 1.0, 32)
const innerRingMat = new THREE.MeshBasicMaterial({
color: 0xffaa00,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide
})
const innerRing = new THREE.Mesh(innerRingGeo, innerRingMat)
innerRing.rotation.x = -Math.PI / 2
innerRing.position.y = 0.05
this.grenadeTargetGroup.add(innerRing)
const crossGeo = new THREE.BufferGeometry()
const crossPoints = [
-0.3, 0.05, 0, 0.3, 0.05, 0,
0, 0.05, -0.3, 0, 0.05, 0.3
]
crossGeo.setAttribute('position', new THREE.Float32BufferAttribute(crossPoints, 3))
const crossMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 })
const cross = new THREE.LineSegments(crossGeo, crossMat)
this.grenadeTargetGroup.add(cross)
this.scene.add(this.grenadeTargetGroup)
}
showGrenadeTarget(playerX, playerY, aimX, aimY, chargePercent) {
if (!this.grenadeTargetGroup) return
const minDist = 3.0
const maxDist = 15.0
const dist = minDist + (maxDist - minDist) * chargePercent
const dx = aimX - playerX
const dy = aimY - playerY
const targetDist = Math.sqrt(dx * dx + dy * dy)
let targetX, targetY
if (targetDist < 0.1) {
targetX = playerX + minDist
targetY = playerY
} else {
const scale = Math.min(dist, targetDist) / targetDist
targetX = playerX + dx * scale
targetY = playerY + dy * scale
}
targetX = Math.max(0.5, Math.min(31.5, targetX))
targetY = Math.max(0.5, Math.min(31.5, targetY))
this.grenadeTargetGroup.position.x = targetX
this.grenadeTargetGroup.position.z = targetY
this.grenadeTargetGroup.visible = true
const scale = 1 + chargePercent * 0.3
this.grenadeTargetGroup.scale.setScalar(scale)
this.grenadeTargetGroup.children[0].material.opacity = 0.4 + chargePercent * 0.4
}
hideGrenadeTarget() {
if (this.grenadeTargetGroup) {
this.grenadeTargetGroup.visible = false
}
}
buildMap(mapData) {
for (const mesh of this.wallMeshes) {
this.scene.remove(mesh)
mesh.geometry.dispose()
mesh.material.dispose()
}
this.wallMeshes = []
const floorGeo = new THREE.PlaneGeometry(GRID_SIZE, GRID_SIZE)
const floorMat = new THREE.MeshLambertMaterial({ color: 0x2a2a3a })
const floor = new THREE.Mesh(floorGeo, floorMat)
floor.rotation.x = -Math.PI / 2
floor.position.set(GRID_SIZE / 2, 0, GRID_SIZE / 2)
floor.receiveShadow = true
this.scene.add(floor)
this.wallMeshes.push(floor)
const wallGeo = new THREE.BoxGeometry(1, 1.5, 1)
const wallMat = new THREE.MeshLambertMaterial({ color: 0x555577 })
const spawnGeo = new THREE.BoxGeometry(1, 0.1, 1)
const spawnMat = new THREE.MeshLambertMaterial({ color: 0x00ff88, transparent: true, opacity: 0.5 })
const zombieSpawnGeo = new THREE.BoxGeometry(1, 0.15, 1)
const zombieSpawnMat = new THREE.MeshLambertMaterial({ color: 0xff4400, transparent: true, opacity: 0.7 })
for (let y = 0; y < GRID_SIZE; y++) {
for (let x = 0; x < GRID_SIZE; x++) {
if (mapData[y] && mapData[y][x] === 1) {
const wall = new THREE.Mesh(wallGeo, wallMat)
wall.position.set(x + 0.5, 0.75, y + 0.5)
wall.castShadow = true
wall.receiveShadow = true
this.scene.add(wall)
this.wallMeshes.push(wall)
} else if (mapData[y] && mapData[y][x] === 2) {
const spawn = new THREE.Mesh(spawnGeo, spawnMat)
spawn.position.set(x + 0.5, 0.05, y + 0.5)
this.scene.add(spawn)
this.wallMeshes.push(spawn)
} else if (mapData[y] && mapData[y][x] === 3) {
const zombieSpawn = new THREE.Mesh(zombieSpawnGeo, zombieSpawnMat)
zombieSpawn.position.set(x + 0.5, 0.08, y + 0.5)
this.scene.add(zombieSpawn)
this.wallMeshes.push(zombieSpawn)
}
}
}
}
createPlayerModel(color = 0x4488ff) {
const group = new THREE.Group()
const bodyGeo = new THREE.CylinderGeometry(PLAYER_SIZE / 2, PLAYER_SIZE / 2, 0.8, 8)
const bodyMat = new THREE.MeshLambertMaterial({ color })
const body = new THREE.Mesh(bodyGeo, bodyMat)
body.position.y = 0.4
body.castShadow = true
group.add(body)
const headGeo = new THREE.SphereGeometry(0.2, 8, 8)
const headMat = new THREE.MeshLambertMaterial({ color: 0xffcc99 })
const head = new THREE.Mesh(headGeo, headMat)
head.position.y = 1.0
head.castShadow = true
group.add(head)
const gunGeo = new THREE.BoxGeometry(0.08, 0.08, 0.5)
const gunMat = new THREE.MeshLambertMaterial({ color: 0x333333 })
const gun = new THREE.Mesh(gunGeo, gunMat)
gun.position.set(0, 0.5, 0.4)
group.add(gun)
group.userData.gun = gun
return group
}
createZombieModel(isElite = false) {
const group = new THREE.Group()
const bodyColor = isElite ? 0x882222 : 0x446633
const headColor = isElite ? 0xaa3333 : 0x557744
const armColor = isElite ? 0xaa3333 : 0x557744
const bodyGeo = new THREE.CylinderGeometry(ZOMBIE_SIZE / 2, ZOMBIE_SIZE / 2, 0.9, 8)
const bodyMat = new THREE.MeshLambertMaterial({ color: bodyColor })
const body = new THREE.Mesh(bodyGeo, bodyMat)
body.position.y = 0.45
body.castShadow = true
group.add(body)
const headGeo = new THREE.SphereGeometry(0.22, 8, 8)
const headMat = new THREE.MeshLambertMaterial({ color: headColor })
const head = new THREE.Mesh(headGeo, headMat)
head.position.y = 1.05
head.castShadow = true
group.add(head)
const armGeo = new THREE.BoxGeometry(0.12, 0.6, 0.12)
const armMat = new THREE.MeshLambertMaterial({ color: armColor })
const leftArm = new THREE.Mesh(armGeo, armMat)
leftArm.position.set(-0.35, 0.5, 0.2)
leftArm.rotation.x = -0.5
group.add(leftArm)
const rightArm = new THREE.Mesh(armGeo, armMat)
rightArm.position.set(0.35, 0.5, 0.2)
rightArm.rotation.x = -0.5
group.add(rightArm)
if (isElite) {
const glowGeo = new THREE.SphereGeometry(0.6, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.2
})
const glow = new THREE.Mesh(glowGeo, glowMat)
glow.position.y = 0.5
group.add(glow)
}
return group
}
addPlayer(id, x, y, color, isLocal = false) {
const model = this.createPlayerModel(color)
model.position.set(x, 0, y)
this.scene.add(model)
this.players.set(id, { model, isLocal })
if (isLocal) this.playerMesh = model
}
removePlayer(id) {
const player = this.players.get(id)
if (player) {
this.scene.remove(player.model)
this.players.delete(id)
}
}
updatePlayer(id, x, y, angle, health) {
const player = this.players.get(id)
if (player) {
player.model.position.x = x
player.model.position.z = y
player.model.rotation.y = angle
if (health !== undefined) {
player.model.userData.health = health
}
}
}
addZombie(id, x, y, isElite = false) {
const model = this.createZombieModel(isElite)
model.position.set(x, 0, y)
this.scene.add(model)
this.zombies.set(id, { model, isElite })
}
removeZombie(id) {
const zombie = this.zombies.get(id)
if (zombie) {
this.scene.remove(zombie.model)
zombie.model.traverse(child => {
if (child.geometry) child.geometry.dispose()
if (child.material) child.material.dispose()
})
this.zombies.delete(id)
}
}
updateZombie(id, x, y, angle, health) {
const zombie = this.zombies.get(id)
if (zombie) {
zombie.model.position.x = x
zombie.model.position.z = y
zombie.model.rotation.y = angle
}
}
addBullet(bullet) {
const group = new THREE.Group()
const isGrenade = bullet.weapon === WEAPONS.GRENADE
const z = bullet.z || 0.5
if (isGrenade) {
const geo = new THREE.SphereGeometry(0.12, 8, 8)
const mat = new THREE.MeshBasicMaterial({ color: 0x44ff44 })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(bullet.x, z, bullet.y)
group.add(mesh)
const glowGeo = new THREE.SphereGeometry(0.18, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0x22cc22,
transparent: true,
opacity: 0.4
})
const glow = new THREE.Mesh(glowGeo, glowMat)
glow.position.set(bullet.x, z, bullet.y)
group.add(glow)
const trailGeo = new THREE.BufferGeometry()
const trailMat = new THREE.LineBasicMaterial({
color: 0x88ff88,
transparent: true,
opacity: 0.6
})
const trailPoints = []
for (let i = 0; i <= 10; i++) {
const t = i / 10
trailPoints.push(bullet.x, z - t * 0.5, bullet.y)
}
trailGeo.setAttribute('position', new THREE.Float32BufferAttribute(trailPoints, 3))
const trail = new THREE.Line(trailGeo, trailMat)
group.add(trail)
} else {
let bulletSize = 0.06
const angle = bullet.angle || 0
switch (bullet.weapon) {
case WEAPONS.MACHINE_GUN:
bulletSize = 0.05
break
case WEAPONS.SHOTGUN:
bulletSize = 0.04
break
}
const trailLength = 2.5
const trailGeo = new THREE.BufferGeometry()
const positions = new Float32Array([
bullet.x - Math.sin(angle) * trailLength, 0.5, bullet.y - Math.cos(angle) * trailLength,
bullet.x, 0.5, bullet.y
])
trailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const trailMat = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1.0,
linewidth: 3
})
const trail = new THREE.Line(trailGeo, trailMat)
group.add(trail)
const geo = new THREE.SphereGeometry(bulletSize, 8, 8)
const mat = new THREE.MeshBasicMaterial({ color: 0xffffaa })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(bullet.x, 0.5, bullet.y)
group.add(mesh)
const glowGeo = new THREE.SphereGeometry(bulletSize * 2, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffff88,
transparent: true,
opacity: 0.4
})
const glow = new THREE.Mesh(glowGeo, glowMat)
glow.position.set(bullet.x, 0.5, bullet.y)
group.add(glow)
}
this.scene.add(group)
this.bullets.push({
id: bullet.id,
group,
x: bullet.x,
y: bullet.y,
z: z,
weapon: bullet.weapon,
angle: bullet.angle || 0,
isGrenade
})
}
removeBullet(id) {
const idx = this.bullets.findIndex(b => b.id === id)
if (idx >= 0) {
const b = this.bullets[idx]
this.scene.remove(b.group)
b.group.traverse(child => {
if (child.geometry) child.geometry.dispose()
if (child.material) child.material.dispose()
})
this.bullets.splice(idx, 1)
}
}
updateBullet(id, x, y, z) {
const bullet = this.bullets.find(b => b.id === id)
if (bullet) {
bullet.x = x
bullet.y = y
bullet.z = z || 0.5
const angle = bullet.angle || 0
bullet.group.children.forEach(child => {
if (child.isMesh) {
child.position.x = x
child.position.y = bullet.z
child.position.z = y
}
if (child.isLine && !bullet.isGrenade) {
const trailLength = 2.5
const positions = child.geometry.attributes.position.array
positions[0] = x - Math.sin(angle) * trailLength
positions[1] = bullet.z
positions[2] = y - Math.cos(angle) * trailLength
positions[3] = x
positions[4] = bullet.z
positions[5] = y
child.geometry.attributes.position.needsUpdate = true
}
})
}
}
addZombieBullet(bullet) {
const group = new THREE.Group()
const angle = bullet.angle || 0
const trailLength = 1.2
const trailGeo = new THREE.BufferGeometry()
const positions = new Float32Array([
bullet.x - Math.sin(angle) * trailLength, 0.5, bullet.y - Math.cos(angle) * trailLength,
bullet.x, 0.5, bullet.y
])
trailGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
const trailMat = new THREE.LineBasicMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.8
})
const trail = new THREE.Line(trailGeo, trailMat)
group.add(trail)
const geo = new THREE.SphereGeometry(0.08, 6, 6)
const mat = new THREE.MeshBasicMaterial({ color: 0xff3333 })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(bullet.x, 0.5, bullet.y)
group.add(mesh)
this.scene.add(group)
this.zombieBullets.push({
id: bullet.id,
group,
x: bullet.x,
y: bullet.y,
angle
})
}
removeZombieBullet(id) {
const idx = this.zombieBullets.findIndex(b => b.id === id)
if (idx >= 0) {
const b = this.zombieBullets[idx]
this.scene.remove(b.group)
b.group.traverse(child => {
if (child.geometry) child.geometry.dispose()
if (child.material) child.material.dispose()
})
this.zombieBullets.splice(idx, 1)
}
}
updateZombieBullet(id, x, y) {
const bullet = this.zombieBullets.find(b => b.id === id)
if (bullet) {
bullet.x = x
bullet.y = y
const angle = bullet.angle || 0
bullet.group.children.forEach(child => {
if (child.isMesh) {
child.position.x = x
child.position.z = y
}
if (child.isLine) {
const trailLength = 1.2
const positions = child.geometry.attributes.position.array
positions[0] = x - Math.sin(angle) * trailLength
positions[1] = 0.5
positions[2] = y - Math.cos(angle) * trailLength
positions[3] = x
positions[4] = 0.5
positions[5] = y
child.geometry.attributes.position.needsUpdate = true
}
})
}
}
addExplosion(x, y, radius) {
const whiteFlashGeo = new THREE.SphereGeometry(radius * 0.8, 16, 16)
const whiteFlashMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.9
})
const whiteFlash = new THREE.Mesh(whiteFlashGeo, whiteFlashMat)
whiteFlash.position.set(x, 0.5, y)
this.scene.add(whiteFlash)
this.effects.push({ mesh: whiteFlash, type: 'whiteFlash', startTime: Date.now(), duration: 300 })
const light = new THREE.PointLight(0xffffff, 3, radius * 4)
light.position.set(x, 2, y)
this.scene.add(light)
this.effects.push({ mesh: light, type: 'light', startTime: Date.now(), duration: 400 })
for (let i = 0; i < 3; i++) {
const r = radius * (0.3 + i * 0.2)
const geo = new THREE.SphereGeometry(r, 12, 12)
const mat = new THREE.MeshBasicMaterial({
color: i === 0 ? 0xffffcc : (i === 1 ? 0xff6600 : 0xff4400),
transparent: true,
opacity: 0.6 - i * 0.15
})
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(x, 0.3, y)
this.scene.add(mesh)
this.effects.push({ mesh, type: 'explosion', startTime: Date.now(), duration: 500 + i * 100 })
}
for (let i = 0; i < 8; i++) {
const angle = (i / 8) * Math.PI * 2
const geo = new THREE.BoxGeometry(0.1, 0.1, 0.1)
const mat = new THREE.MeshBasicMaterial({ color: 0xff8800 })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(x, 0.3, y)
this.scene.add(mesh)
this.effects.push({
mesh,
type: 'debris',
startTime: Date.now(),
duration: 600,
vx: Math.cos(angle) * 2,
vy: 1.5 + Math.random() * 1,
vz: Math.sin(angle) * 2
})
}
}
addMuzzleFlash(x, y, angle) {
const dist = 0.6
const flashGeo = new THREE.SphereGeometry(0.25, 8, 8)
const flashMat = new THREE.MeshBasicMaterial({
color: 0xffff00,
transparent: true,
opacity: 1.0
})
const flash = new THREE.Mesh(flashGeo, flashMat)
flash.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist)
flash.scale.set(1.5, 1, 1)
this.scene.add(flash)
this.effects.push({ mesh: flash, type: 'muzzle', startTime: Date.now(), duration: 100 })
const glowGeo = new THREE.SphereGeometry(0.4, 8, 8)
const glowMat = new THREE.MeshBasicMaterial({
color: 0xff6600,
transparent: true,
opacity: 0.6
})
const glow = new THREE.Mesh(glowGeo, glowMat)
glow.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist)
this.scene.add(glow)
this.effects.push({ mesh: glow, type: 'muzzle', startTime: Date.now(), duration: 150 })
const light = new THREE.PointLight(0xffaa00, 2, 5)
light.position.set(x + Math.sin(angle) * dist, 0.6, y + Math.cos(angle) * dist)
this.scene.add(light)
this.effects.push({ mesh: light, type: 'light', startTime: Date.now(), duration: 100 })
}
addHitEffect(x, y) {
const geo = new THREE.SphereGeometry(0.2, 8, 8)
const mat = new THREE.MeshBasicMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.9
})
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(x, 0.5, y)
this.scene.add(mesh)
this.effects.push({ mesh, type: 'hit', startTime: Date.now(), duration: 200 })
for (let i = 0; i < 5; i++) {
const sparkGeo = new THREE.BoxGeometry(0.05, 0.05, 0.05)
const sparkMat = new THREE.MeshBasicMaterial({ color: 0xff4400 })
const spark = new THREE.Mesh(sparkGeo, sparkMat)
spark.position.set(x, 0.5, y)
this.scene.add(spark)
const angle = Math.random() * Math.PI * 2
const speed = 1 + Math.random() * 2
this.effects.push({
mesh: spark,
type: 'spark',
startTime: Date.now(),
duration: 300,
vx: Math.cos(angle) * speed,
vy: 1 + Math.random() * 2,
vz: Math.sin(angle) * speed
})
}
}
addLoot(id, x, y, type = 'ammo') {
const isHealth = type === 'health'
const color = isHealth ? 0xff4444 : 0x00ffcc
const emissive = isHealth ? 0xaa2222 : 0x00aa88
const geo = new THREE.BoxGeometry(0.4, 0.4, 0.4)
const mat = new THREE.MeshLambertMaterial({ color, emissive, emissiveIntensity: 0.3 })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.set(x, 0.3, y)
this.scene.add(mesh)
this.loots.set(id, { mesh, type })
if (isHealth) {
const crossGeo = new THREE.BoxGeometry(0.35, 0.1, 0.1)
const crossMat = new THREE.MeshBasicMaterial({ color: 0xffffff })
const crossH = new THREE.Mesh(crossGeo, crossMat)
crossH.position.set(x, 0.55, y)
this.scene.add(crossH)
const crossV = new THREE.Mesh(crossGeo, crossMat)
crossV.position.set(x, 0.55, y)
crossV.rotation.y = Math.PI / 2
this.scene.add(crossV)
this.loots.get(id).cross = [crossH, crossV]
}
}
removeLoot(id) {
const loot = this.loots.get(id)
if (loot) {
this.scene.remove(loot.mesh)
loot.mesh.geometry.dispose()
loot.mesh.material.dispose()
if (loot.cross) {
loot.cross.forEach(c => {
this.scene.remove(c)
c.geometry.dispose()
c.material.dispose()
})
}
this.loots.delete(id)
}
}
updateCamera(targetX, targetY) {
const target = new THREE.Vector3(targetX, 0, targetY)
const desiredPos = target.clone().add(this.cameraOffset)
this.camera.position.lerp(desiredPos, 0.1)
this.camera.lookAt(target)
}
getMouseGroundPos(mouseX, mouseY) {
const mouse = new THREE.Vector2(
(mouseX / window.innerWidth) * 2 - 1,
-(mouseY / window.innerHeight) * 2 + 1
)
const raycaster = new THREE.Raycaster()
raycaster.setFromCamera(mouse, this.camera)
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
const intersection = new THREE.Vector3()
raycaster.ray.intersectPlane(groundPlane, intersection)
if (intersection) {
return { x: intersection.x, y: intersection.z }
}
return { x: 0, y: 0 }
}
updateEffects() {
const now = Date.now()
const gravity = -9.8
for (let i = this.effects.length - 1; i >= 0; i--) {
const effect = this.effects[i]
const elapsed = (now - effect.startTime) / 1000
const progress = elapsed * 1000 / effect.duration
if (progress >= 1) {
this.scene.remove(effect.mesh)
if (effect.mesh.geometry) effect.mesh.geometry.dispose()
if (effect.mesh.material) effect.mesh.material.dispose()
this.effects.splice(i, 1)
} else {
if (effect.type === 'whiteFlash') {
const scale = 1 + progress * 2
effect.mesh.scale.setScalar(scale)
if (effect.mesh.material.transparent !== false) {
effect.mesh.material.opacity = 1.0 * (1 - progress)
}
} else if (effect.type === 'explosion') {
effect.mesh.material.opacity = (0.8 - effect.mesh.material.opacity * 0.2) * (1 - progress)
effect.mesh.scale.setScalar(1 + progress * 1.5)
} else if (effect.type === 'muzzle') {
effect.mesh.material.opacity = (1 - progress)
effect.mesh.scale.setScalar(1 + progress * 0.5)
} else if (effect.type === 'hit') {
effect.mesh.material.opacity = 0.9 * (1 - progress)
effect.mesh.scale.setScalar(1 + progress * 2)
} else if (effect.type === 'spark' || effect.type === 'debris') {
effect.mesh.position.x += effect.vx * 0.016
effect.mesh.position.z += effect.vz * 0.016
effect.vy += gravity * 0.016
effect.mesh.position.y += effect.vy * 0.016
effect.mesh.material.opacity = 1 - progress
} else if (effect.type === 'light') {
effect.mesh.intensity = 2 * (1 - progress)
}
}
}
for (const bullet of this.bullets) {
if (!bullet.isGrenade && bullet.trail) {
const positions = bullet.trail.geometry.attributes.position.array
positions[0] = bullet.x - Math.sin(bullet.angle) * 1.5
positions[1] = 0.5
positions[2] = bullet.y - Math.cos(bullet.angle) * 1.5
positions[3] = bullet.x
positions[4] = 0.5
positions[5] = bullet.y
bullet.trail.geometry.attributes.position.needsUpdate = true
}
}
for (const [, loot] of this.loots) {
loot.mesh.rotation.y += 0.03
loot.mesh.position.y = 0.3 + Math.sin(Date.now() * 0.003) * 0.1
}
}
render() {
this.updateEffects()
this.renderer.render(this.scene, this.camera)
}
destroy() {
this.renderer.dispose()
this.scene.traverse(child => {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose())
} else {
child.material.dispose()
}
}
})
}
}

174
frontend/src/main.js Normal file
View File

@@ -0,0 +1,174 @@
import { MSG_TYPE } from './utils/constants.js'
import { GameEngine } from './game/engine.js'
import { LobbyUI } from './ui/lobby.js'
import { HUD } from './ui/hud.js'
import { SettingsUI } from './ui/settings.js'
import './style.css'
const WS_URL = `ws://${window.location.hostname}:8080/ws`
class App {
constructor() {
this.appEl = document.getElementById('app')
this.lobbyEl = document.createElement('div')
this.gameCanvasEl = document.createElement('canvas')
this.hudEl = document.createElement('div')
this.settingsEl = document.createElement('div')
this.appEl.appendChild(this.lobbyEl)
this.appEl.appendChild(this.gameCanvasEl)
this.appEl.appendChild(this.hudEl)
this.appEl.appendChild(this.settingsEl)
this.gameCanvasEl.style.display = 'none'
this.hudEl.style.display = 'none'
this.settingsEl.style.display = 'none'
this.engine = null
this.lobby = new LobbyUI(this.lobbyEl)
this.hud = new HUD(this.hudEl)
this.settings = new SettingsUI(this.settingsEl)
this.playerId = null
this.roomId = null
this.isHost = false
this.playerName = ''
this._setupLobby()
this._setupSettings()
}
_setupLobby() {
this.lobby.onCreateRoom = (name) => {
this.playerName = name
this._ensureConnection().then(() => {
this.engine.network.createRoom(name)
})
}
this.lobby.onJoinRoom = (roomId, name) => {
this.playerName = name
this._ensureConnection().then(() => {
this.engine.network.joinRoom(roomId, name)
})
}
this.lobby.onRefreshRooms = () => {
this._ensureConnection().then(() => {
this.engine.network.requestRoomList()
})
}
this.lobby.onReady = () => {
if (this.engine) this.engine.network.ready()
}
this.lobby.onStartGame = () => {
if (this.engine) this.engine.network.startGame()
}
this.lobby.onLeaveRoom = () => {
if (this.engine) {
this.engine.network.leaveRoom()
this.roomId = null
this.isHost = false
this.lobby.render()
}
}
}
_setupSettings() {
this.settings.onKeyChange = (bindings) => {
if (this.engine) {
for (const [action, key] of Object.entries(bindings)) {
this.engine.input.updateKeyBinding(action, key)
}
}
}
}
async _ensureConnection() {
if (!this.engine) {
this.engine = new GameEngine(this.gameCanvasEl)
try {
await this.engine.connect(WS_URL)
this._setupNetworkHandlers()
} catch (e) {
console.error('Failed to connect:', e)
alert('Failed to connect to server. Make sure the server is running on port 8080.')
}
} else if (!this.engine.network.connected) {
try {
await this.engine.connect(WS_URL)
this._setupNetworkHandlers()
} catch (e) {
console.error('Failed to reconnect:', e)
}
}
}
_setupNetworkHandlers() {
const net = this.engine.network
net.on(MSG_TYPE.ROOM_LIST, (data) => {
this.lobby.updateRoomList(data.rooms)
})
net.on(MSG_TYPE.ROOM_STATE, (data) => {
this.playerId = data.playerId || this.playerId
this.roomId = data.roomId
this.isHost = data.isHost || false
this.lobby.showRoom(data, this.isHost, this.playerId)
})
net.on(MSG_TYPE.GAME_STARTED, (data) => {
this.playerId = data.playerId
this.lobbyEl.style.display = 'none'
this.gameCanvasEl.style.display = 'block'
this.hudEl.style.display = 'flex'
this.hud.show()
this.engine.onStateUpdate = (type, state) => {
if (type === 'state') {
this._updateHUD(state)
}
}
})
net.on(MSG_TYPE.ERROR, (data) => {
console.error('Error:', data.message)
})
}
_updateHUD(state) {
const localPlayer = this.engine.players.get(this.engine.localPlayerId)
if (localPlayer) {
this.hud.updateHealth(localPlayer.health)
}
this.hud.updateWeapons(this.engine.currentWeaponIndex, this.engine.weaponAmmo)
this.hud.updateGrenadeCharge(this.engine.getGrenadeChargePercent())
this.hud.updateInfo(
this.engine.waveNumber,
this.engine.score,
this.engine.gameTime
)
}
showSettings() {
if (this.engine) {
this.settings.show(this.engine.input.getKeyBindings())
}
}
}
const app = new App()
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape') {
if (app.settings.visible) {
app.settings.hide()
} else if (app.engine && app.engine.running) {
app.showSettings()
}
}
})

View File

@@ -0,0 +1,113 @@
import { MSG_TYPE } from '../utils/constants.js'
export class NetworkClient {
constructor() {
this.ws = null
this.handlers = new Map()
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.connected = false
this.playerId = null
this.roomId = null
}
connect(url) {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(url)
this.ws.onopen = () => {
this.connected = true
this.reconnectAttempts = 0
resolve()
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
this._dispatch(msg)
} catch (e) {
console.error('Failed to parse message:', e)
}
}
this.ws.onclose = () => {
this.connected = false
this._dispatch({ type: MSG_TYPE.ERROR, data: { message: 'Disconnected from server' } })
}
this.ws.onerror = (err) => {
reject(err)
}
} catch (e) {
reject(e)
}
})
}
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
this.connected = false
}
}
on(type, handler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, [])
}
this.handlers.get(type).push(handler)
}
off(type, handler) {
if (this.handlers.has(type)) {
const list = this.handlers.get(type)
const idx = list.indexOf(handler)
if (idx >= 0) list.splice(idx, 1)
}
}
send(type, data = {}) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, data }))
}
}
_dispatch(msg) {
const handlers = this.handlers.get(msg.type)
if (handlers) {
for (const h of handlers) {
h(msg.data)
}
}
}
createRoom(playerName) {
this.send(MSG_TYPE.CREATE_ROOM, { playerName })
}
joinRoom(roomId, playerName) {
this.send(MSG_TYPE.JOIN_ROOM, { roomId, playerName })
}
leaveRoom() {
this.send(MSG_TYPE.LEAVE_ROOM, {})
}
requestRoomList() {
this.send(MSG_TYPE.ROOM_LIST, {})
}
ready() {
this.send(MSG_TYPE.READY, {})
}
startGame() {
this.send(MSG_TYPE.START_GAME, {})
}
sendInput(inputState) {
this.send(MSG_TYPE.PLAYER_INPUT, inputState)
}
}

499
frontend/src/style.css Normal file
View File

@@ -0,0 +1,499 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #0a0a1a;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #e0e0e0;
}
#app {
width: 100vw;
height: 100vh;
position: relative;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.lobby-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px;
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a3e 50%, #0a0a2e 100%);
}
.lobby-title {
font-size: 48px;
color: #ff4444;
text-shadow: 0 0 20px rgba(255, 68, 68, 0.5);
margin-bottom: 8px;
letter-spacing: 2px;
}
.lobby-subtitle {
font-size: 16px;
color: #8888aa;
margin-bottom: 40px;
}
.lobby-section {
margin-bottom: 24px;
text-align: center;
}
.lobby-label {
display: block;
margin-bottom: 8px;
color: #aaaacc;
font-size: 14px;
}
.lobby-input {
padding: 12px 20px;
border: 2px solid #333366;
border-radius: 8px;
background: #1a1a3e;
color: #e0e0e0;
font-size: 16px;
width: 280px;
outline: none;
transition: border-color 0.3s;
}
.lobby-input:focus {
border-color: #4488ff;
}
.lobby-actions {
display: flex;
gap: 16px;
margin-bottom: 32px;
}
.lobby-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 0.5px;
}
.lobby-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.lobby-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.lobby-btn-primary {
background: linear-gradient(135deg, #4488ff, #2266dd);
color: white;
}
.lobby-btn-secondary {
background: linear-gradient(135deg, #555577, #444466);
color: #ccccee;
}
.lobby-btn-danger {
background: linear-gradient(135deg, #ff4444, #cc2222);
color: white;
}
.lobby-btn-small {
padding: 8px 16px;
font-size: 14px;
background: linear-gradient(135deg, #44aa44, #228822);
color: white;
}
.lobby-room-list {
width: 100%;
max-width: 600px;
}
.lobby-room-title {
font-size: 20px;
color: #aaaacc;
margin-bottom: 16px;
border-bottom: 1px solid #333366;
padding-bottom: 8px;
}
.lobby-room-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.lobby-empty {
text-align: center;
color: #666688;
padding: 20px;
}
.lobby-room-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #1a1a3e;
border: 1px solid #333366;
border-radius: 8px;
transition: border-color 0.2s;
}
.lobby-room-card:hover {
border-color: #4488ff;
}
.lobby-room-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.lobby-room-name {
font-weight: 600;
color: #e0e0ff;
}
.lobby-room-players {
font-size: 13px;
color: #8888aa;
}
.room-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px;
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a3e 50%, #0a0a2e 100%);
}
.room-title {
font-size: 28px;
color: #ff8844;
margin-bottom: 24px;
}
.room-player-list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 500px;
margin-bottom: 24px;
}
.room-player-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #1a1a3e;
border: 1px solid #333366;
border-radius: 8px;
}
.room-player-local {
border-color: #4488ff;
}
.room-player-name {
font-weight: 600;
}
.room-player-status {
font-size: 14px;
}
.status-host {
color: #ffaa00;
}
.status-ready {
color: #44ff44;
}
.status-waiting {
color: #ffaa00;
}
.room-actions {
display: flex;
gap: 16px;
}
.hud-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
display: none;
}
.hud-health-bar {
position: absolute;
bottom: 20px;
left: 20px;
width: 200px;
height: 24px;
background: #333;
border-radius: 12px;
overflow: hidden;
border: 2px solid #555;
}
.hud-health-fill {
height: 100%;
width: 100%;
background: #44ff44;
transition: width 0.3s, background-color 0.3s;
border-radius: 10px;
}
.hud-health-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 13px;
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.hud-weapon-panel {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 4px;
}
.hud-weapon-slot {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 12px;
background: rgba(20, 20, 40, 0.85);
border: 2px solid #444466;
border-radius: 8px;
min-width: 70px;
}
.hud-weapon-active {
border-color: #4488ff;
background: rgba(30, 30, 80, 0.9);
box-shadow: 0 0 10px rgba(68, 136, 255, 0.3);
}
.hud-weapon-key {
font-size: 11px;
color: #888;
margin-bottom: 2px;
}
.hud-weapon-name {
font-size: 12px;
font-weight: 600;
color: #ccccee;
margin-bottom: 2px;
}
.hud-weapon-ammo {
font-size: 14px;
font-weight: 700;
color: #ffaa00;
}
.hud-grenade-charge {
position: absolute;
bottom: 60px;
right: 20px;
width: 200px;
height: 12px;
background: #333;
border-radius: 6px;
overflow: hidden;
border: 1px solid #555;
}
.hud-grenade-charge-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #44ff44, #ff4444);
transition: width 0.1s;
border-radius: 5px;
}
.hud-info-panel {
position: absolute;
top: 20px;
left: 20px;
display: flex;
gap: 20px;
}
.hud-info-item {
font-size: 16px;
font-weight: 600;
color: #e0e0ff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.hud-crosshair {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
pointer-events: none;
}
.hud-crosshair::before,
.hud-crosshair::after {
content: '';
position: absolute;
background: rgba(255, 255, 255, 0.8);
}
.hud-crosshair::before {
top: 50%;
left: 0;
right: 0;
height: 2px;
transform: translateY(-50%);
}
.hud-crosshair::after {
left: 50%;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
}
.hud-kill-feed {
position: absolute;
top: 60px;
right: 20px;
display: flex;
flex-direction: column;
gap: 4px;
max-width: 300px;
}
.hud-kill-entry {
padding: 6px 12px;
background: rgba(20, 20, 40, 0.75);
border-radius: 4px;
font-size: 13px;
color: #ffaa00;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.settings-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
pointer-events: auto;
}
.settings-panel {
background: #1a1a3e;
border: 2px solid #333366;
border-radius: 12px;
padding: 32px;
min-width: 400px;
}
.settings-title {
font-size: 24px;
color: #aaaacc;
margin-bottom: 24px;
text-align: center;
}
.settings-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #222244;
}
.settings-label {
font-size: 14px;
color: #aaaacc;
}
.settings-key-btn {
padding: 6px 16px;
background: #2a2a4e;
border: 1px solid #444466;
border-radius: 6px;
color: #e0e0ff;
font-size: 14px;
cursor: pointer;
min-width: 100px;
text-align: center;
}
.settings-key-btn:hover {
border-color: #4488ff;
}
.settings-key-btn-capturing {
border-color: #ffaa00;
color: #ffaa00;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.settings-btn-row {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 24px;
}

149
frontend/src/ui/hud.js Normal file
View File

@@ -0,0 +1,149 @@
import { WEAPONS, WEAPON_CONFIG } from '../utils/constants.js'
export class HUD {
constructor(container) {
this.container = container
this.container.className = 'hud-container'
this.container.innerHTML = ''
this.visible = false
this._build()
}
_build() {
this.healthBar = document.createElement('div')
this.healthBar.className = 'hud-health-bar'
this.healthBar.innerHTML = '<div class="hud-health-fill"></div><span class="hud-health-text">100</span>'
this.container.appendChild(this.healthBar)
this.weaponPanel = document.createElement('div')
this.weaponPanel.className = 'hud-weapon-panel'
const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE]
this.weaponSlots = []
for (let i = 0; i < weaponList.length; i++) {
const slot = document.createElement('div')
slot.className = 'hud-weapon-slot'
slot.dataset.index = i
const keyLabel = document.createElement('span')
keyLabel.className = 'hud-weapon-key'
keyLabel.textContent = (i + 1).toString()
const weaponName = document.createElement('span')
weaponName.className = 'hud-weapon-name'
weaponName.textContent = WEAPON_CONFIG[weaponList[i]].name
const ammoText = document.createElement('span')
ammoText.className = 'hud-weapon-ammo'
slot.appendChild(keyLabel)
slot.appendChild(weaponName)
slot.appendChild(ammoText)
this.weaponSlots.push({ slot, ammoText })
this.weaponPanel.appendChild(slot)
}
this.container.appendChild(this.weaponPanel)
this.grenadeChargeBar = document.createElement('div')
this.grenadeChargeBar.className = 'hud-grenade-charge'
this.grenadeChargeBar.innerHTML = '<div class="hud-grenade-charge-fill"></div>'
this.grenadeChargeBar.style.display = 'none'
this.container.appendChild(this.grenadeChargeBar)
this.infoPanel = document.createElement('div')
this.infoPanel.className = 'hud-info-panel'
this.waveText = document.createElement('span')
this.waveText.className = 'hud-info-item'
this.waveText.textContent = 'Wave: 0'
this.scoreText = document.createElement('span')
this.scoreText.className = 'hud-info-item'
this.scoreText.textContent = 'Score: 0'
this.timeText = document.createElement('span')
this.timeText.className = 'hud-info-item'
this.timeText.textContent = 'Time: 0:00'
this.infoPanel.appendChild(this.waveText)
this.infoPanel.appendChild(this.scoreText)
this.infoPanel.appendChild(this.timeText)
this.container.appendChild(this.infoPanel)
this.crosshair = document.createElement('div')
this.crosshair.className = 'hud-crosshair'
this.container.appendChild(this.crosshair)
this.killFeed = document.createElement('div')
this.killFeed.className = 'hud-kill-feed'
this.container.appendChild(this.killFeed)
}
show() {
this.visible = true
this.container.style.display = 'flex'
}
hide() {
this.visible = false
this.container.style.display = 'none'
}
updateHealth(health) {
const fill = this.healthBar.querySelector('.hud-health-fill')
const text = this.healthBar.querySelector('.hud-health-text')
const pct = Math.max(0, Math.min(100, health))
fill.style.width = pct + '%'
if (pct > 60) fill.style.backgroundColor = '#44ff44'
else if (pct > 30) fill.style.backgroundColor = '#ffaa00'
else fill.style.backgroundColor = '#ff4444'
text.textContent = Math.ceil(pct)
}
updateWeapons(currentIndex, ammo) {
const weaponList = [WEAPONS.PISTOL, WEAPONS.MACHINE_GUN, WEAPONS.SHOTGUN, WEAPONS.GRENADE]
for (let i = 0; i < this.weaponSlots.length; i++) {
const slot = this.weaponSlots[i]
slot.slot.classList.toggle('hud-weapon-active', i === currentIndex)
const weaponKey = weaponList[i]
const currentAmmo = ammo[weaponKey]
if (currentAmmo === Infinity) {
slot.ammoText.textContent = '∞'
} else {
slot.ammoText.textContent = currentAmmo + '/' + WEAPON_CONFIG[weaponKey].maxAmmo
}
}
}
updateGrenadeCharge(percent) {
if (percent > 0) {
this.grenadeChargeBar.style.display = 'block'
const fill = this.grenadeChargeBar.querySelector('.hud-grenade-charge-fill')
fill.style.width = (percent * 100) + '%'
} else {
this.grenadeChargeBar.style.display = 'none'
}
}
updateInfo(wave, score, time) {
this.waveText.textContent = 'Wave: ' + wave
this.scoreText.textContent = 'Score: ' + score
const mins = Math.floor(time / 60)
const secs = Math.floor(time % 60)
this.timeText.textContent = `Time: ${mins}:${secs.toString().padStart(2, '0')}`
}
addKillFeed(message) {
const entry = document.createElement('div')
entry.className = 'hud-kill-entry'
entry.textContent = message
this.killFeed.appendChild(entry)
setTimeout(() => {
if (entry.parentNode) entry.parentNode.removeChild(entry)
}, 4000)
while (this.killFeed.children.length > 5) {
this.killFeed.removeChild(this.killFeed.firstChild)
}
}
}

192
frontend/src/ui/lobby.js Normal file
View File

@@ -0,0 +1,192 @@
export class LobbyUI {
constructor(container) {
this.container = container
this.onCreateRoom = null
this.onJoinRoom = null
this.onReady = null
this.onStartGame = null
this.onLeaveRoom = null
this.onRefreshRooms = null
this.currentRoom = null
this.isHost = false
this.playerId = null
this.render()
}
render() {
this.container.innerHTML = ''
this.container.className = 'lobby-container'
const title = document.createElement('h1')
title.textContent = '🧟 Zombie Crisis 3'
title.className = 'lobby-title'
this.container.appendChild(title)
const subtitle = document.createElement('p')
subtitle.textContent = 'Multiplayer Online Zombie Shooter'
subtitle.className = 'lobby-subtitle'
this.container.appendChild(subtitle)
const nameSection = document.createElement('div')
nameSection.className = 'lobby-section'
const nameLabel = document.createElement('label')
nameLabel.textContent = 'Player Name:'
nameLabel.className = 'lobby-label'
this.nameInput = document.createElement('input')
this.nameInput.type = 'text'
this.nameInput.value = 'Player' + Math.floor(Math.random() * 1000)
this.nameInput.className = 'lobby-input'
this.nameInput.maxLength = 16
nameSection.appendChild(nameLabel)
nameSection.appendChild(this.nameInput)
this.container.appendChild(nameSection)
const actions = document.createElement('div')
actions.className = 'lobby-actions'
const createBtn = document.createElement('button')
createBtn.textContent = '🏠 Create Room'
createBtn.className = 'lobby-btn lobby-btn-primary'
createBtn.onclick = () => {
if (this.onCreateRoom) this.onCreateRoom(this.nameInput.value)
}
actions.appendChild(createBtn)
const refreshBtn = document.createElement('button')
refreshBtn.textContent = '🔄 Refresh Rooms'
refreshBtn.className = 'lobby-btn lobby-btn-secondary'
refreshBtn.onclick = () => {
if (this.onRefreshRooms) this.onRefreshRooms()
}
actions.appendChild(refreshBtn)
this.container.appendChild(actions)
this.roomListSection = document.createElement('div')
this.roomListSection.className = 'lobby-room-list'
const roomTitle = document.createElement('h2')
roomTitle.textContent = 'Available Rooms'
roomTitle.className = 'lobby-room-title'
this.roomListSection.appendChild(roomTitle)
this.roomListContent = document.createElement('div')
this.roomListContent.className = 'lobby-room-content'
this.roomListContent.innerHTML = '<p class="lobby-empty">No rooms available. Create one!</p>'
this.roomListSection.appendChild(this.roomListContent)
this.container.appendChild(this.roomListSection)
}
updateRoomList(rooms) {
this.roomListContent.innerHTML = ''
if (!rooms || rooms.length === 0) {
this.roomListContent.innerHTML = '<p class="lobby-empty">No rooms available. Create one!</p>'
return
}
for (const room of rooms) {
const roomCard = document.createElement('div')
roomCard.className = 'lobby-room-card'
const roomInfo = document.createElement('div')
roomInfo.className = 'lobby-room-info'
const roomName = document.createElement('span')
roomName.className = 'lobby-room-name'
roomName.textContent = room.hostName + "'s Room"
const roomPlayers = document.createElement('span')
roomPlayers.className = 'lobby-room-players'
roomPlayers.textContent = `${room.playerCount}/4 players`
roomInfo.appendChild(roomName)
roomInfo.appendChild(roomPlayers)
const joinBtn = document.createElement('button')
joinBtn.textContent = 'Join'
joinBtn.className = 'lobby-btn lobby-btn-small'
joinBtn.disabled = room.playerCount >= 4
joinBtn.onclick = () => {
if (this.onJoinRoom) this.onJoinRoom(room.id, this.nameInput.value)
}
roomCard.appendChild(roomInfo)
roomCard.appendChild(joinBtn)
this.roomListContent.appendChild(roomCard)
}
}
showRoom(roomData, isHost, playerId) {
this.currentRoom = roomData
this.isHost = isHost
this.playerId = playerId
this.container.innerHTML = ''
this.container.className = 'room-container'
const title = document.createElement('h2')
title.textContent = '🧟 Room: ' + (roomData.hostName || 'Unknown') + "'s Room"
title.className = 'room-title'
this.container.appendChild(title)
const playerList = document.createElement('div')
playerList.className = 'room-player-list'
for (const player of roomData.players) {
const playerCard = document.createElement('div')
playerCard.className = 'room-player-card' + (player.id === playerId ? ' room-player-local' : '')
const playerName = document.createElement('span')
playerName.className = 'room-player-name'
playerName.textContent = player.name + (player.id === roomData.hostId ? ' 👑' : '')
const playerStatus = document.createElement('span')
playerStatus.className = 'room-player-status'
if (player.id === roomData.hostId) {
playerStatus.textContent = 'Host'
playerStatus.classList.add('status-host')
} else {
playerStatus.textContent = player.ready ? '✅ Ready' : '⏳ Not Ready'
playerStatus.classList.add(player.ready ? 'status-ready' : 'status-waiting')
}
playerCard.appendChild(playerName)
playerCard.appendChild(playerStatus)
playerList.appendChild(playerCard)
}
this.container.appendChild(playerList)
const actions = document.createElement('div')
actions.className = 'room-actions'
if (!isHost) {
const readyBtn = document.createElement('button')
const myPlayer = roomData.players.find(p => p.id === playerId)
readyBtn.textContent = myPlayer && myPlayer.ready ? '❌ Unready' : '✅ Ready'
readyBtn.className = 'lobby-btn ' + (myPlayer && myPlayer.ready ? 'lobby-btn-secondary' : 'lobby-btn-primary')
readyBtn.onclick = () => {
if (this.onReady) this.onReady()
}
actions.appendChild(readyBtn)
} else {
const allReady = roomData.players.filter(p => p.id !== roomData.hostId).every(p => p.ready)
const startBtn = document.createElement('button')
startBtn.textContent = '🚀 Start Game'
startBtn.className = 'lobby-btn lobby-btn-primary'
startBtn.disabled = !allReady || roomData.players.length < 1
startBtn.onclick = () => {
if (this.onStartGame) this.onStartGame()
}
actions.appendChild(startBtn)
}
const leaveBtn = document.createElement('button')
leaveBtn.textContent = '🚪 Leave Room'
leaveBtn.className = 'lobby-btn lobby-btn-danger'
leaveBtn.onclick = () => {
if (this.onLeaveRoom) this.onLeaveRoom()
}
actions.appendChild(leaveBtn)
this.container.appendChild(actions)
}
destroy() {
this.container.innerHTML = ''
}
}

131
frontend/src/ui/settings.js Normal file
View File

@@ -0,0 +1,131 @@
export class SettingsUI {
constructor(container) {
this.container = container
this.visible = false
this.onKeyChange = null
this.defaultBindings = {
moveUp: 'KeyW',
moveDown: 'KeyS',
moveLeft: 'KeyA',
moveRight: 'KeyD',
weapon1: 'Digit1',
weapon2: 'Digit2',
weapon3: 'Digit3',
weapon4: 'Digit4'
}
this.bindings = { ...this.defaultBindings }
}
show(currentBindings) {
if (currentBindings) this.bindings = { ...currentBindings }
this.visible = true
this._render()
}
hide() {
this.visible = false
this.container.innerHTML = ''
this.container.style.display = 'none'
}
_render() {
this.container.style.display = 'flex'
this.container.className = 'settings-container'
this.container.innerHTML = ''
const panel = document.createElement('div')
panel.className = 'settings-panel'
const title = document.createElement('h2')
title.textContent = '⚙ Key Bindings'
title.className = 'settings-title'
panel.appendChild(title)
const labels = {
moveUp: 'Move Up',
moveDown: 'Move Down',
moveLeft: 'Move Left',
moveRight: 'Move Right',
weapon1: 'Weapon 1 (Pistol)',
weapon2: 'Weapon 2 (MG)',
weapon3: 'Weapon 3 (Shotgun)',
weapon4: 'Weapon 4 (Grenade)'
}
for (const [action, key] of Object.entries(this.bindings)) {
const row = document.createElement('div')
row.className = 'settings-row'
const label = document.createElement('span')
label.className = 'settings-label'
label.textContent = labels[action] || action
const keyBtn = document.createElement('button')
keyBtn.className = 'settings-key-btn'
keyBtn.textContent = this._formatKey(key)
keyBtn.onclick = () => this._captureKey(action, keyBtn)
row.appendChild(label)
row.appendChild(keyBtn)
panel.appendChild(row)
}
const btnRow = document.createElement('div')
btnRow.className = 'settings-btn-row'
const saveBtn = document.createElement('button')
saveBtn.textContent = 'Save'
saveBtn.className = 'lobby-btn lobby-btn-primary'
saveBtn.onclick = () => {
if (this.onKeyChange) this.onKeyChange(this.bindings)
this.hide()
}
const cancelBtn = document.createElement('button')
cancelBtn.textContent = 'Cancel'
cancelBtn.className = 'lobby-btn lobby-btn-secondary'
cancelBtn.onclick = () => this.hide()
const resetBtn = document.createElement('button')
resetBtn.textContent = 'Reset Defaults'
resetBtn.className = 'lobby-btn lobby-btn-danger'
resetBtn.onclick = () => {
this.bindings = { ...this.defaultBindings }
this._render()
}
btnRow.appendChild(saveBtn)
btnRow.appendChild(cancelBtn)
btnRow.appendChild(resetBtn)
panel.appendChild(btnRow)
this.container.appendChild(panel)
}
_captureKey(action, btn) {
btn.textContent = 'Press a key...'
btn.classList.add('settings-key-btn-capturing')
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
this.bindings[action] = e.code
btn.textContent = this._formatKey(e.code)
btn.classList.remove('settings-key-btn-capturing')
window.removeEventListener('keydown', handler)
}
window.addEventListener('keydown', handler)
}
_formatKey(code) {
return code
.replace('Key', '')
.replace('Digit', '')
.replace('Arrow', '')
.replace('Left', '←')
.replace('Right', '→')
.replace('Up', '↑')
.replace('Down', '↓')
}
}

View File

@@ -0,0 +1,109 @@
export const GRID_SIZE = 32
export const CELL_SIZE = 1
export const PLAYER_SIZE = 0.8
export const ZOMBIE_SIZE = 0.8
export const WALL_SIZE = 1
export const SPAWN_SIZE = 1
export const TICK_RATE = 30
export const TICK_INTERVAL = 1000 / TICK_RATE
export const WEAPONS = {
PISTOL: 'pistol',
MACHINE_GUN: 'machine_gun',
SHOTGUN: 'shotgun',
GRENADE: 'grenade'
}
export const WEAPON_CONFIG = {
[WEAPONS.PISTOL]: {
name: 'Pistol',
damage: 25,
fireRate: 400,
ammo: Infinity,
maxAmmo: Infinity,
speed: 20,
spread: 0,
pellets: 1,
range: 30,
auto: false,
chargeable: false
},
[WEAPONS.MACHINE_GUN]: {
name: 'Machine Gun',
damage: 15,
fireRate: 100,
ammo: 100,
maxAmmo: 100,
speed: 25,
spread: 0.05,
pellets: 1,
range: 25,
auto: true,
chargeable: false
},
[WEAPONS.SHOTGUN]: {
name: 'Shotgun',
damage: 20,
fireRate: 800,
ammo: 20,
maxAmmo: 20,
speed: 18,
spread: 0.15,
pellets: 6,
range: 12,
auto: false,
chargeable: false
},
[WEAPONS.GRENADE]: {
name: 'Grenade',
damage: 100,
fireRate: 1500,
ammo: 10,
maxAmmo: 10,
speed: 12,
spread: 0,
pellets: 1,
range: 15,
auto: false,
chargeable: true,
maxCharge: 2000,
explosionRadius: 3
}
}
export const ZOMBIE_CONFIG = {
BASE_HEALTH: 100,
BASE_SPEED: 2,
DAMAGE: 10,
ATTACK_RATE: 1000,
SPAWN_INTERVAL_BASE: 3000,
SPAWN_INTERVAL_MIN: 800,
DIFFICULTY_INCREASE_INTERVAL: 30000,
HEALTH_INCREASE_PER_WAVE: 20,
SPEED_INCREASE_PER_WAVE: 0.1,
LOOT_DROP_CHANCE: 0.3
}
export const PLAYER_CONFIG = {
MAX_HEALTH: 100,
SPEED: 5,
INVULNERABLE_TIME: 500
}
export const MSG_TYPE = {
CREATE_ROOM: 'create_room',
JOIN_ROOM: 'join_room',
LEAVE_ROOM: 'leave_room',
ROOM_LIST: 'room_list',
ROOM_STATE: 'room_state',
READY: 'ready',
START_GAME: 'start_game',
GAME_STARTED: 'game_started',
PLAYER_INPUT: 'player_input',
GAME_STATE: 'game_state',
PLAYER_JOIN: 'player_join',
PLAYER_LEAVE: 'player_leave',
ERROR: 'error',
PING: 'ping',
PONG: 'pong'
}

250
frontend/src/utils/grid.js Normal file
View File

@@ -0,0 +1,250 @@
import { GRID_SIZE, CELL_SIZE, WALL_SIZE } from './constants.js'
export class Grid {
constructor(mapData) {
this.width = GRID_SIZE
this.height = GRID_SIZE
this.cells = []
this.parseMap(mapData)
}
parseMap(mapData) {
this.cells = []
for (let y = 0; y < this.height; y++) {
this.cells[y] = []
for (let x = 0; x < this.width; x++) {
this.cells[y][x] = mapData[y] ? mapData[y][x] || 0 : 0
}
}
}
isWall(gridX, gridY) {
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return true
return this.cells[gridY][gridX] === 1
}
isSpawnPoint(gridX, gridY) {
if (gridX < 0 || gridX >= this.width || gridY < 0 || gridY >= this.height) return false
return this.cells[gridY][gridX] === 2
}
worldToGrid(wx, wy) {
return {
x: Math.floor(wx / CELL_SIZE),
y: Math.floor(wy / CELL_SIZE)
}
}
gridToWorld(gx, gy) {
return {
x: gx * CELL_SIZE + CELL_SIZE / 2,
y: gy * CELL_SIZE + CELL_SIZE / 2
}
}
isWalkable(wx, wy, size) {
const half = size / 2
const corners = [
{ x: wx - half, y: wy - half },
{ x: wx + half, y: wy - half },
{ x: wx - half, y: wy + half },
{ x: wx + half, y: wy + half }
]
for (const corner of corners) {
const g = this.worldToGrid(corner.x, corner.y)
if (this.isWall(g.x, g.y)) return false
}
return true
}
getSpawnPoints() {
const points = []
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.cells[y][x] === 2) {
points.push({ x, y })
}
}
}
return points
}
findPath(startX, startY, endX, endY) {
const sg = this.worldToGrid(startX, startY)
const eg = this.worldToGrid(endX, endY)
if (this.isWall(eg.x, eg.y)) return null
const openSet = []
const closedSet = new Set()
const cameFrom = new Map()
const heuristic = (ax, ay, bx, by) => Math.abs(ax - bx) + Math.abs(ay - by)
const startKey = `${sg.x},${sg.y}`
openSet.push({
x: sg.x,
y: sg.y,
g: 0,
h: heuristic(sg.x, sg.y, eg.x, eg.y),
f: heuristic(sg.x, sg.y, eg.x, eg.y)
})
const directions = [
{ dx: 0, dy: -1 },
{ dx: 0, dy: 1 },
{ dx: -1, dy: 0 },
{ dx: 1, dy: 0 },
{ dx: -1, dy: -1 },
{ dx: 1, dy: -1 },
{ dx: -1, dy: 1 },
{ dx: 1, dy: 1 }
]
let iterations = 0
const maxIterations = 2000
while (openSet.length > 0 && iterations < maxIterations) {
iterations++
openSet.sort((a, b) => a.f - b.f)
const current = openSet.shift()
const currentKey = `${current.x},${current.y}`
if (current.x === eg.x && current.y === eg.y) {
const path = []
let key = currentKey
while (cameFrom.has(key)) {
const [cx, cy] = key.split(',').map(Number)
const wp = this.gridToWorld(cx, cy)
path.unshift({ x: wp.x, y: wp.y })
key = cameFrom.get(key)
}
return path
}
closedSet.add(currentKey)
for (const dir of directions) {
const nx = current.x + dir.dx
const ny = current.y + dir.dy
const nKey = `${nx},${ny}`
if (closedSet.has(nKey)) continue
if (this.isWall(nx, ny)) continue
if (nx < 0 || nx >= this.width || ny < 0 || ny >= this.height) continue
if (dir.dx !== 0 && dir.dy !== 0) {
if (this.isWall(current.x + dir.dx, current.y) || this.isWall(current.x, current.y + dir.dy)) {
continue
}
}
const isDiagonal = dir.dx !== 0 && dir.dy !== 0
const moveCost = isDiagonal ? 1.414 : 1
const g = current.g + moveCost
const existing = openSet.find(n => n.x === nx && n.y === ny)
if (existing) {
if (g < existing.g) {
existing.g = g
existing.f = g + existing.h
cameFrom.set(nKey, currentKey)
}
} else {
const h = heuristic(nx, ny, eg.x, eg.y)
openSet.push({ x: nx, y: ny, g, h, f: g + h })
cameFrom.set(nKey, currentKey)
}
}
}
return null
}
}
export function generateDefaultMap() {
const map = []
for (let y = 0; y < GRID_SIZE; y++) {
map[y] = []
for (let x = 0; x < GRID_SIZE; x++) {
if (x === 0 || x === GRID_SIZE - 1 || y === 0 || y === GRID_SIZE - 1) {
map[y][x] = 1
} else {
map[y][x] = 0
}
}
}
const wallSegments = [
{ x1: 5, y1: 5, x2: 5, y2: 10 },
{ x1: 10, y1: 3, x2: 15, y2: 3 },
{ x1: 20, y1: 5, x2: 20, y2: 12 },
{ x1: 8, y1: 15, x2: 14, y2: 15 },
{ x1: 25, y1: 10, x2: 25, y2: 18 },
{ x1: 3, y1: 20, x2: 8, y2: 20 },
{ x1: 15, y1: 20, x2: 15, y2: 26 },
{ x1: 22, y1: 22, x2: 28, y2: 22 },
{ x1: 5, y1: 25, x2: 10, y2: 25 },
{ x1: 12, y1: 8, x2: 12, y2: 12 },
{ x1: 18, y1: 16, x2: 22, y2: 16 },
{ x1: 27, y1: 5, x2: 27, y2: 9 },
{ x1: 8, y1: 27, x2: 13, y2: 27 },
{ x1: 18, y1: 26, x2: 18, y2: 30 }
]
for (const seg of wallSegments) {
if (seg.x1 === seg.x2) {
for (let y = seg.y1; y <= seg.y2; y++) {
if (seg.x1 > 0 && seg.x1 < GRID_SIZE - 1 && y > 0 && y < GRID_SIZE - 1) {
map[y][seg.x1] = 1
}
}
} else {
for (let x = seg.x1; x <= seg.x2; x++) {
if (x > 0 && x < GRID_SIZE - 1 && seg.y1 > 0 && seg.y1 < GRID_SIZE - 1) {
map[seg.y1][x] = 1
}
}
}
}
const spawnPoints = [
{ x: 2, y: 2 },
{ x: 29, y: 2 },
{ x: 2, y: 29 },
{ x: 29, y: 29 }
]
for (const sp of spawnPoints) {
map[sp.y][sp.x] = 2
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const ny = sp.y + dy
const nx = sp.x + dx
if (ny > 0 && ny < GRID_SIZE - 1 && nx > 0 && nx < GRID_SIZE - 1) {
if (map[ny][nx] === 1) map[ny][nx] = 0
}
}
}
}
const zombieSpawns = [
{ x: 16, y: 2 },
{ x: 2, y: 16 },
{ x: 29, y: 16 },
{ x: 16, y: 29 },
{ x: 16, y: 16 }
]
for (const sp of zombieSpawns) {
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const ny = sp.y + dy
const nx = sp.x + dx
if (ny > 0 && ny < GRID_SIZE - 1 && nx > 0 && nx < GRID_SIZE - 1) {
if (map[ny][nx] === 1) map[ny][nx] = 0
}
}
}
}
return map
}

106
frontend/src/utils/input.js Normal file
View File

@@ -0,0 +1,106 @@
export class InputManager {
constructor() {
this.keys = {}
this.mouse = { x: 0, y: 0, left: false, right: false }
this.keyBindings = {
moveUp: 'KeyW',
moveDown: 'KeyS',
moveLeft: 'KeyA',
moveRight: 'KeyD',
weapon1: 'Digit1',
weapon2: 'Digit2',
weapon3: 'Digit3',
weapon4: 'Digit4'
}
this.pendingActions = []
this.sequenceNumber = 0
this.onKeyDown = null
this.onKeyUp = null
this._onKeyDown = (e) => {
this.keys[e.code] = true
if (this.onKeyDown) this.onKeyDown(e)
}
this._onKeyUp = (e) => {
this.keys[e.code] = false
if (this.onKeyUp) this.onKeyUp(e)
}
this._onMouseMove = (e) => {
this.mouse.x = e.clientX
this.mouse.y = e.clientY
}
this._onMouseDown = (e) => {
if (e.button === 0) this.mouse.left = true
if (e.button === 2) this.mouse.right = true
}
this._onMouseUp = (e) => {
if (e.button === 0) this.mouse.left = false
if (e.button === 2) this.mouse.right = false
}
this._onContextMenu = (e) => e.preventDefault()
}
attach() {
window.addEventListener('keydown', this._onKeyDown)
window.addEventListener('keyup', this._onKeyUp)
window.addEventListener('mousemove', this._onMouseMove)
window.addEventListener('mousedown', this._onMouseDown)
window.addEventListener('mouseup', this._onMouseUp)
window.addEventListener('contextmenu', this._onContextMenu)
}
detach() {
window.removeEventListener('keydown', this._onKeyDown)
window.removeEventListener('keyup', this._onKeyUp)
window.removeEventListener('mousemove', this._onMouseMove)
window.removeEventListener('mousedown', this._onMouseDown)
window.removeEventListener('mouseup', this._onMouseUp)
window.removeEventListener('contextmenu', this._onContextMenu)
}
getMovement() {
let dx = 0, dy = 0
if (this.keys[this.keyBindings.moveUp] || this.keys['ArrowUp']) dy -= 1
if (this.keys[this.keyBindings.moveDown] || this.keys['ArrowDown']) dy += 1
if (this.keys[this.keyBindings.moveLeft] || this.keys['ArrowLeft']) dx -= 1
if (this.keys[this.keyBindings.moveRight] || this.keys['ArrowRight']) dx += 1
if (dx !== 0 && dy !== 0) {
dx *= 0.7071
dy *= 0.7071
}
return { dx, dy }
}
getSelectedWeapon() {
if (this.keys[this.keyBindings.weapon1]) return 0
if (this.keys[this.keyBindings.weapon2]) return 1
if (this.keys[this.keyBindings.weapon3]) return 2
if (this.keys[this.keyBindings.weapon4]) return 3
return -1
}
buildInputState(mouseGroundPos) {
const movement = this.getMovement()
const weaponIdx = this.getSelectedWeapon()
this.sequenceNumber++
return {
seq: this.sequenceNumber,
dx: movement.dx,
dy: movement.dy,
aimX: mouseGroundPos.x,
aimY: mouseGroundPos.y,
firing: this.mouse.left,
weaponIndex: weaponIdx
}
}
updateKeyBinding(action, keyCode) {
if (this.keyBindings.hasOwnProperty(action)) {
this.keyBindings[action] = keyCode
}
}
getKeyBindings() {
return { ...this.keyBindings }
}
}

13
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 3000,
proxy: {
'/ws': {
target: 'ws://localhost:8080',
ws: true
}
}
}
})

4
start-backend.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
echo Starting Zombie Crisis 3 Backend Server...
cd /d %~dp0backend
call build-and-run.bat

4
start-frontend.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
echo Starting Zombie Crisis 3 Frontend...
cd /d %~dp0frontend
npm run dev