From 8ab9a9fa70dfeb151cc4b4bd3275ff000fba476c Mon Sep 17 00:00:00 2001 From: wfz <1040079213@qq.com> Date: Wed, 15 Apr 2026 20:58:16 +0800 Subject: [PATCH] 1 --- .gitignore | 4 + backend/build-and-run.bat | 35 + backend/dependency-reduced-pom.xml | 39 + backend/mvnw.cmd | 61 + backend/pom.xml | 65 + .../java/com/zombie/game/GameServerMain.java | 31 + .../java/com/zombie/game/model/Bullet.java | 150 +++ .../java/com/zombie/game/model/Constants.java | 56 + .../java/com/zombie/game/model/GameMap.java | 216 ++++ .../java/com/zombie/game/model/GameWorld.java | 448 +++++++ .../main/java/com/zombie/game/model/Loot.java | 42 + .../java/com/zombie/game/model/Player.java | 203 +++ .../main/java/com/zombie/game/model/Room.java | 88 ++ .../java/com/zombie/game/model/Zombie.java | 129 ++ .../java/com/zombie/game/server/GameLoop.java | 46 + .../game/server/GameWebSocketServer.java | 373 ++++++ frontend/index.html | 12 + frontend/package-lock.json | 1113 +++++++++++++++++ frontend/package.json | 17 + frontend/src/game/engine.js | 470 +++++++ frontend/src/game/scene.js | 798 ++++++++++++ frontend/src/main.js | 174 +++ frontend/src/network/client.js | 113 ++ frontend/src/style.css | 499 ++++++++ frontend/src/ui/hud.js | 149 +++ frontend/src/ui/lobby.js | 192 +++ frontend/src/ui/settings.js | 131 ++ frontend/src/utils/constants.js | 109 ++ frontend/src/utils/grid.js | 250 ++++ frontend/src/utils/input.js | 106 ++ frontend/vite.config.js | 13 + start-backend.bat | 4 + start-frontend.bat | 4 + 33 files changed, 6140 insertions(+) create mode 100644 .gitignore create mode 100644 backend/build-and-run.bat create mode 100644 backend/dependency-reduced-pom.xml create mode 100644 backend/mvnw.cmd create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/zombie/game/GameServerMain.java create mode 100644 backend/src/main/java/com/zombie/game/model/Bullet.java create mode 100644 backend/src/main/java/com/zombie/game/model/Constants.java create mode 100644 backend/src/main/java/com/zombie/game/model/GameMap.java create mode 100644 backend/src/main/java/com/zombie/game/model/GameWorld.java create mode 100644 backend/src/main/java/com/zombie/game/model/Loot.java create mode 100644 backend/src/main/java/com/zombie/game/model/Player.java create mode 100644 backend/src/main/java/com/zombie/game/model/Room.java create mode 100644 backend/src/main/java/com/zombie/game/model/Zombie.java create mode 100644 backend/src/main/java/com/zombie/game/server/GameLoop.java create mode 100644 backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/game/engine.js create mode 100644 frontend/src/game/scene.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/network/client.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/ui/hud.js create mode 100644 frontend/src/ui/lobby.js create mode 100644 frontend/src/ui/settings.js create mode 100644 frontend/src/utils/constants.js create mode 100644 frontend/src/utils/grid.js create mode 100644 frontend/src/utils/input.js create mode 100644 frontend/vite.config.js create mode 100644 start-backend.bat create mode 100644 start-frontend.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..780ea56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/frontend/dist/ +/frontend/node_modules/ +/backend/target/ +/.idea/ diff --git a/backend/build-and-run.bat b/backend/build-and-run.bat new file mode 100644 index 0000000..16e43b9 --- /dev/null +++ b/backend/build-and-run.bat @@ -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 diff --git a/backend/dependency-reduced-pom.xml b/backend/dependency-reduced-pom.xml new file mode 100644 index 0000000..a72055f --- /dev/null +++ b/backend/dependency-reduced-pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + com.zombie + zombie-crisis-server + 1.0.0 + + + + maven-jar-plugin + 3.3.0 + + + + com.zombie.game.GameServerMain + + + + + + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + + + + 17 + 17 + UTF-8 + + diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd new file mode 100644 index 0000000..5735015 --- /dev/null +++ b/backend/mvnw.cmd @@ -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% diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..618c2b2 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + com.zombie + zombie-crisis-server + 1.0.0 + jar + + + 17 + 17 + UTF-8 + + + + + org.java-websocket + Java-WebSocket + 1.5.4 + + + com.google.code.gson + gson + 2.10.1 + + + org.slf4j + slf4j-simple + 2.0.9 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.zombie.game.GameServerMain + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + + + diff --git a/backend/src/main/java/com/zombie/game/GameServerMain.java b/backend/src/main/java/com/zombie/game/GameServerMain.java new file mode 100644 index 0000000..fc4e467 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/GameServerMain.java @@ -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(); + } + })); + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Bullet.java b/backend/src/main/java/com/zombie/game/model/Bullet.java new file mode 100644 index 0000000..46c6a03 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Bullet.java @@ -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 toStateMap() { + Map 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; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Constants.java b/backend/src/main/java/com/zombie/game/model/Constants.java new file mode 100644 index 0000000..2b1ec71 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Constants.java @@ -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"; +} diff --git a/backend/src/main/java/com/zombie/game/model/GameMap.java b/backend/src/main/java/com/zombie/game/model/GameMap.java new file mode 100644 index 0000000..eb9a5da --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/GameMap.java @@ -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 getSpawnPoints() { + List 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 getZombieSpawnPoints() { + List points = new ArrayList<>(); + int[][] zombieSpawns = {{8, 8}, {24, 24}}; + for (int[] sp : zombieSpawns) { + points.add(sp); + } + return points; + } + + public List 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 openSet = new PriorityQueue<>(Comparator.comparingDouble(n -> n.f)); + Set closedSet = new HashSet<>(); + Map 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 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; + } + } +} diff --git a/backend/src/main/java/com/zombie/game/model/GameWorld.java b/backend/src/main/java/com/zombie/game/model/GameWorld.java new file mode 100644 index 0000000..4a7a9b4 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/GameWorld.java @@ -0,0 +1,448 @@ +package com.zombie.game.model; + +import java.util.*; + +public class GameWorld { + private GameMap map; + private Map players; + private Map zombies; + private Map bullets; + private Map zombieBullets; + private Map 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> explosions; + private List removedBullets; + private List 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 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 getPlayers() { return players.values(); } + public Collection getZombies() { return zombies.values(); } + public Collection getBullets() { return bullets.values(); } + public Collection 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 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 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 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 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 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 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 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 fireWeapon(Player player, float aimX, float aimY) { + return fireWeapon(player, aimX, aimY, 0); + } + + public List fireWeapon(Player player, float aimX, float aimY, float chargePercent) { + List 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 buildGameState(String forPlayerId) { + Map state = new LinkedHashMap<>(); + + List> playerStates = new ArrayList<>(); + for (Player p : players.values()) { + playerStates.add(p.toStateMap()); + } + state.put("players", playerStates); + + List> zombieStates = new ArrayList<>(); + for (Zombie z : zombies.values()) { + zombieStates.add(z.toStateMap()); + } + state.put("zombies", zombieStates); + + List> bulletStates = new ArrayList<>(); + for (Bullet b : bullets.values()) { + bulletStates.add(b.toStateMap()); + } + state.put("bullets", bulletStates); + + List> zombieBulletStates = new ArrayList<>(); + for (Bullet b : zombieBullets.values()) { + zombieBulletStates.add(b.toStateMap()); + } + state.put("zombieBullets", zombieBulletStates); + + List> 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 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; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Loot.java b/backend/src/main/java/com/zombie/game/model/Loot.java new file mode 100644 index 0000000..feaf7ba --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Loot.java @@ -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 toStateMap() { + Map map = new LinkedHashMap<>(); + map.put("id", id); + map.put("x", x); + map.put("y", y); + map.put("type", type); + return map; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Player.java b/backend/src/main/java/com/zombie/game/model/Player.java new file mode 100644 index 0000000..7263209 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Player.java @@ -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 toStateMap() { + Map 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; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Room.java b/backend/src/main/java/com/zombie/game/model/Room.java new file mode 100644 index 0000000..0110729 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Room.java @@ -0,0 +1,88 @@ +package com.zombie.game.model; + +import java.util.*; + +public class Room { + private String id; + private String hostId; + private Map 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 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 toStateMap(String playerId) { + Map map = new LinkedHashMap<>(); + map.put("roomId", id); + map.put("hostId", hostId); + map.put("isHost", hostId.equals(playerId)); + map.put("playerId", playerId); + + List> playerList = new ArrayList<>(); + int index = 0; + for (Player p : players.values()) { + Map 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 toRoomListMap() { + Map 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; + } +} diff --git a/backend/src/main/java/com/zombie/game/model/Zombie.java b/backend/src/main/java/com/zombie/game/model/Zombie.java new file mode 100644 index 0000000..0befa37 --- /dev/null +++ b/backend/src/main/java/com/zombie/game/model/Zombie.java @@ -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 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 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 toStateMap() { + Map 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; + } +} diff --git a/backend/src/main/java/com/zombie/game/server/GameLoop.java b/backend/src/main/java/com/zombie/game/server/GameLoop.java new file mode 100644 index 0000000..2415cbc --- /dev/null +++ b/backend/src/main/java/com/zombie/game/server/GameLoop.java @@ -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; + } +} diff --git a/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java b/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java new file mode 100644 index 0000000..edb97be --- /dev/null +++ b/backend/src/main/java/com/zombie/game/server/GameWebSocketServer.java @@ -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 rooms; + private Map connectionToPlayer; + private Map playerToConnection; + private Map activeGames; + private Map 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> roomList = new ArrayList<>(); + for (Room room : rooms.values()) { + if (!room.isGameStarted()) { + roomList.add(room.toRoomListMap()); + } + } + Map 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 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 data = new LinkedHashMap<>(); + data.put("playerId", player.getId()); + data.put("mapData", serializeMapData(world.getMapData())); + + List> playerList = new ArrayList<>(); + int idx = 0; + for (Player p : room.getPlayers()) { + Map 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 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> roomList = new ArrayList<>(); + for (Room room : rooms.values()) { + if (!room.isGameStarted()) { + roomList.add(room.toRoomListMap()); + } + } + Map 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> serializeMapData(int[][] cells) { + List> result = new ArrayList<>(); + for (int[] row : cells) { + List 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 msg = new LinkedHashMap<>(); + msg.put("type", type); + msg.put("data", data); + conn.send(gson.toJson(msg)); + } + } + + private void sendError(WebSocket conn, String message) { + Map data = new LinkedHashMap<>(); + data.put("message", message); + sendToConnection(conn, Constants.MSG_ERROR, data); + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a6e83c5 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Zombie Crisis 3 - Multiplayer + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..8080634 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1113 @@ +{ + "name": "zombie-crisis-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zombie-crisis-frontend", + "version": "1.0.0", + "dependencies": { + "three": "^0.170.0" + }, + "devDependencies": { + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmmirror.com/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fb90160 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/game/engine.js b/frontend/src/game/engine.js new file mode 100644 index 0000000..2e897b7 --- /dev/null +++ b/frontend/src/game/engine.js @@ -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() + } +} diff --git a/frontend/src/game/scene.js b/frontend/src/game/scene.js new file mode 100644 index 0000000..e0704ca --- /dev/null +++ b/frontend/src/game/scene.js @@ -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() + } + } + }) + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..94e864c --- /dev/null +++ b/frontend/src/main.js @@ -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() + } + } +}) diff --git a/frontend/src/network/client.js b/frontend/src/network/client.js new file mode 100644 index 0000000..c519f0a --- /dev/null +++ b/frontend/src/network/client.js @@ -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) + } +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..0844783 --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/src/ui/hud.js b/frontend/src/ui/hud.js new file mode 100644 index 0000000..3ab55ef --- /dev/null +++ b/frontend/src/ui/hud.js @@ -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 = '
100' + 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 = '
' + 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) + } + } +} diff --git a/frontend/src/ui/lobby.js b/frontend/src/ui/lobby.js new file mode 100644 index 0000000..9327e1b --- /dev/null +++ b/frontend/src/ui/lobby.js @@ -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 = '

No rooms available. Create one!

' + this.roomListSection.appendChild(this.roomListContent) + this.container.appendChild(this.roomListSection) + } + + updateRoomList(rooms) { + this.roomListContent.innerHTML = '' + if (!rooms || rooms.length === 0) { + this.roomListContent.innerHTML = '

No rooms available. Create one!

' + 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 = '' + } +} diff --git a/frontend/src/ui/settings.js b/frontend/src/ui/settings.js new file mode 100644 index 0000000..3d6f5af --- /dev/null +++ b/frontend/src/ui/settings.js @@ -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', '↓') + } +} diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js new file mode 100644 index 0000000..968b6ff --- /dev/null +++ b/frontend/src/utils/constants.js @@ -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' +} diff --git a/frontend/src/utils/grid.js b/frontend/src/utils/grid.js new file mode 100644 index 0000000..3bf8b59 --- /dev/null +++ b/frontend/src/utils/grid.js @@ -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 +} diff --git a/frontend/src/utils/input.js b/frontend/src/utils/input.js new file mode 100644 index 0000000..24771c3 --- /dev/null +++ b/frontend/src/utils/input.js @@ -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 } + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..1d1fb50 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 3000, + proxy: { + '/ws': { + target: 'ws://localhost:8080', + ws: true + } + } + } +}) diff --git a/start-backend.bat b/start-backend.bat new file mode 100644 index 0000000..e385a39 --- /dev/null +++ b/start-backend.bat @@ -0,0 +1,4 @@ +@echo off +echo Starting Zombie Crisis 3 Backend Server... +cd /d %~dp0backend +call build-and-run.bat diff --git a/start-frontend.bat b/start-frontend.bat new file mode 100644 index 0000000..b8fee72 --- /dev/null +++ b/start-frontend.bat @@ -0,0 +1,4 @@ +@echo off +echo Starting Zombie Crisis 3 Frontend... +cd /d %~dp0frontend +npm run dev