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