diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 3fd1157..bcc8cd0 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -12,7 +12,8 @@
"Bash(where mvn.cmd)",
"Bash(mvn clean:*)",
"Bash(mvn test:*)",
- "Bash(python3:*)"
+ "Bash(python3:*)",
+ "Bash(npm --version)"
],
"deny": []
}
diff --git a/.gitignore b/.gitignore
index ac17331..bd0b988 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,4 +47,8 @@ target
/.settings
/.project
/ratel-landlords/.project
-.vscode
\ No newline at end of file
+.vscode
+
+# Web client
+node_modules
+web-client/dist
\ No newline at end of file
diff --git a/web-client/index.html b/web-client/index.html
new file mode 100644
index 0000000..eec3a6e
--- /dev/null
+++ b/web-client/index.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+ Gomoku
+
+
+
+
+
+
+
+
diff --git a/web-client/package-lock.json b/web-client/package-lock.json
new file mode 100644
index 0000000..0166b6e
--- /dev/null
+++ b/web-client/package-lock.json
@@ -0,0 +1,1122 @@
+{
+ "name": "caro-web-client",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "caro-web-client",
+ "version": "1.0.0",
+ "dependencies": {
+ "phaser": "^3.87.0"
+ },
+ "devDependencies": {
+ "vite": "^6.3.1"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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.npmjs.org/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/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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/phaser": {
+ "version": "3.90.0",
+ "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz",
+ "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/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.npmjs.org/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/web-client/package.json b/web-client/package.json
new file mode 100644
index 0000000..d5ee155
--- /dev/null
+++ b/web-client/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "caro-web-client",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "phaser": "^3.87.0"
+ },
+ "devDependencies": {
+ "vite": "^6.3.1"
+ }
+}
diff --git a/web-client/src/config/game-config.js b/web-client/src/config/game-config.js
new file mode 100644
index 0000000..d0be180
--- /dev/null
+++ b/web-client/src/config/game-config.js
@@ -0,0 +1,18 @@
+import Phaser from 'phaser';
+import { BootScene } from '../scenes/boot-scene.js';
+import { MenuScene } from '../scenes/menu-scene.js';
+import { GameScene } from '../scenes/game-scene.js';
+
+/** Phaser game configuration */
+export const gameConfig = {
+ type: Phaser.AUTO,
+ width: 800,
+ height: 800,
+ parent: 'game-container',
+ backgroundColor: '#1a1a2e',
+ scale: {
+ mode: Phaser.Scale.FIT,
+ autoCenter: Phaser.Scale.CENTER_BOTH
+ },
+ scene: [BootScene, MenuScene, GameScene]
+};
diff --git a/web-client/src/config/protocol-constants.js b/web-client/src/config/protocol-constants.js
new file mode 100644
index 0000000..068013c
--- /dev/null
+++ b/web-client/src/config/protocol-constants.js
@@ -0,0 +1,48 @@
+/**
+ * Protocol constants matching server's ServerEventCode and ClientEventCode enums.
+ * @module protocol-constants
+ */
+
+/** @enum {string} Codes the client sends TO the server */
+export const ServerEventCode = Object.freeze({
+ NICKNAME_SET: 'CODE_CLIENT_NICKNAME_SET',
+ INFO_SET: 'CODE_CLIENT_INFO_SET',
+ ROOM_CREATE: 'CODE_ROOM_CREATE',
+ ROOM_CREATE_PVE: 'CODE_ROOM_CREATE_PVE',
+ ROOM_JOIN: 'CODE_ROOM_JOIN',
+ GET_ROOMS: 'CODE_GET_ROOMS',
+ GAME_MOVE: 'CODE_GAME_MOVE',
+ GAME_READY: 'CODE_GAME_READY',
+ CLIENT_EXIT: 'CODE_CLIENT_EXIT',
+ GAME_WATCH: 'CODE_GAME_WATCH',
+ GAME_WATCH_EXIT: 'CODE_GAME_WATCH_EXIT',
+ HEARTBEAT: 'CODE_CLIENT_HEAD_BEAT',
+ GAME_STARTING: 'CODE_GAME_STARTING',
+});
+
+/** @enum {string} Codes the server sends TO the client */
+export const ClientEventCode = Object.freeze({
+ CLIENT_CONNECT: 'CODE_CLIENT_CONNECT',
+ NICKNAME_SET: 'CODE_CLIENT_NICKNAME_SET',
+ SHOW_OPTIONS: 'CODE_SHOW_OPTIONS',
+ SHOW_ROOMS: 'CODE_SHOW_ROOMS',
+ ROOM_CREATE_SUCCESS: 'CODE_ROOM_CREATE_SUCCESS',
+ ROOM_JOIN_SUCCESS: 'CODE_ROOM_JOIN_SUCCESS',
+ ROOM_JOIN_FAIL_FULL: 'CODE_ROOM_JOIN_FAIL_BY_FULL',
+ ROOM_JOIN_FAIL_INEXIST: 'CODE_ROOM_JOIN_FAIL_BY_INEXIST',
+ ROOM_PLAY_FAIL_INEXIST: 'CODE_ROOM_PLAY_FAIL_BY_INEXIST',
+ GAME_STARTING: 'CODE_GAME_STARTING',
+ GAME_MOVE_SUCCESS: 'CODE_GAME_MOVE_SUCCESS',
+ GAME_MOVE_INVALID: 'CODE_GAME_MOVE_INVALID',
+ GAME_MOVE_OCCUPIED: 'CODE_GAME_MOVE_OCCUPIED',
+ GAME_MOVE_OUT_OF_BOUNDS: 'CODE_GAME_MOVE_OUT_OF_BOUNDS',
+ GAME_MOVE_NOT_YOUR_TURN: 'CODE_GAME_MOVE_NOT_YOUR_TURN',
+ GAME_OVER: 'CODE_GAME_OVER',
+ GAME_READY: 'CODE_GAME_READY',
+ CLIENT_EXIT: 'CODE_CLIENT_EXIT',
+ CLIENT_KICK: 'CODE_CLIENT_KICK',
+ GAME_WATCH: 'CODE_GAME_WATCH',
+ GAME_WATCH_SUCCESSFUL: 'CODE_GAME_WATCH_SUCCESSFUL',
+ PVE_DIFFICULTY_NOT_SUPPORT: 'CODE_PVE_DIFFICULTY_NOT_SUPPORT',
+ SHOW_BOARD: 'CODE_SHOW_BOARD',
+});
diff --git a/web-client/src/main.js b/web-client/src/main.js
new file mode 100644
index 0000000..7ddde79
--- /dev/null
+++ b/web-client/src/main.js
@@ -0,0 +1,7 @@
+import Phaser from 'phaser';
+import { gameConfig } from './config/game-config.js';
+
+/** @type {Phaser.Game} */
+const game = new Phaser.Game(gameConfig);
+
+export default game;
diff --git a/web-client/src/objects/board.js b/web-client/src/objects/board.js
new file mode 100644
index 0000000..d7270b9
--- /dev/null
+++ b/web-client/src/objects/board.js
@@ -0,0 +1,108 @@
+/**
+ * Board — Phaser GameObject that renders the 15x15 Gomoku grid.
+ * Draws wood background, grid lines, star points, and coordinate labels.
+ * @module board
+ */
+
+import Phaser from 'phaser';
+
+const BOARD_SIZE = 15;
+const PADDING = 50;
+const STAR_POINTS = [[3,3],[3,11],[7,7],[11,3],[11,11]];
+const WOOD_COLOR = 0xdcb35c;
+const LINE_COLOR = 0x8b6914;
+const LABEL_COLOR = '#5a4510';
+
+export class Board extends Phaser.GameObjects.Graphics {
+ /**
+ * @param {Phaser.Scene} scene
+ * @param {number} canvasSize - total canvas pixel size
+ */
+ constructor(scene, canvasSize) {
+ super(scene);
+ this.canvasSize = canvasSize;
+ this.cellSize = (canvasSize - 2 * PADDING) / (BOARD_SIZE - 1);
+ scene.add.existing(this);
+ this.draw();
+ }
+
+ /**
+ * Get pixel X for a board column.
+ * @param {number} col - 0-14
+ * @returns {number}
+ */
+ gridX(col) { return PADDING + col * this.cellSize; }
+
+ /**
+ * Get pixel Y for a board row.
+ * @param {number} row - 0-14
+ * @returns {number}
+ */
+ gridY(row) { return PADDING + row * this.cellSize; }
+
+ /**
+ * Convert pixel coordinates to nearest grid intersection.
+ * @param {number} px - pixel X
+ * @param {number} py - pixel Y
+ * @returns {{ row: number, col: number }|null} - null if out of bounds
+ */
+ pixelToGrid(px, py) {
+ const col = Math.round((px - PADDING) / this.cellSize);
+ const row = Math.round((py - PADDING) / this.cellSize);
+ if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return null;
+ return { row, col };
+ }
+
+ /** @returns {number} */
+ getCellSize() { return this.cellSize; }
+
+ /** Draw the full board (background, grid, stars, labels). */
+ draw() {
+ this.clear();
+ this._drawBackground();
+ this._drawGrid();
+ this._drawStarPoints();
+ this._drawLabels();
+ }
+
+ /** @private */
+ _drawBackground() {
+ this.fillStyle(WOOD_COLOR, 1);
+ this.fillRect(0, 0, this.canvasSize, this.canvasSize);
+ // Subtle grain lines
+ this.lineStyle(1, LINE_COLOR, 0.1);
+ for (let i = 0; i < this.canvasSize; i += 7) {
+ this.lineBetween(0, i, this.canvasSize, i);
+ }
+ }
+
+ /** @private */
+ _drawGrid() {
+ this.lineStyle(1, LINE_COLOR, 1);
+ for (let i = 0; i < BOARD_SIZE; i++) {
+ this.lineBetween(this.gridX(i), this.gridY(0), this.gridX(i), this.gridY(BOARD_SIZE - 1));
+ this.lineBetween(this.gridX(0), this.gridY(i), this.gridX(BOARD_SIZE - 1), this.gridY(i));
+ }
+ }
+
+ /** @private */
+ _drawStarPoints() {
+ this.fillStyle(LINE_COLOR, 1);
+ for (const [r, c] of STAR_POINTS) {
+ this.fillCircle(this.gridX(c), this.gridY(r), 4);
+ }
+ }
+
+ /** @private */
+ _drawLabels() {
+ for (let i = 0; i < BOARD_SIZE; i++) {
+ const letter = String.fromCharCode(65 + i);
+ this.scene.add.text(this.gridX(i), PADDING - 25, letter, {
+ fontSize: '12px', color: LABEL_COLOR, fontFamily: 'sans-serif'
+ }).setOrigin(0.5);
+ this.scene.add.text(PADDING - 25, this.gridY(i), String(i + 1), {
+ fontSize: '12px', color: LABEL_COLOR, fontFamily: 'sans-serif'
+ }).setOrigin(0.5);
+ }
+ }
+}
diff --git a/web-client/src/objects/stone.js b/web-client/src/objects/stone.js
new file mode 100644
index 0000000..1ce37bf
--- /dev/null
+++ b/web-client/src/objects/stone.js
@@ -0,0 +1,79 @@
+/**
+ * Stone — Phaser GameObject for a single Gomoku stone.
+ * Renders black or white circle with gradient effect and drop animation.
+ * @module stone
+ */
+
+import Phaser from 'phaser';
+
+const STONE_RADIUS_RATIO = 0.43;
+
+export class Stone extends Phaser.GameObjects.Graphics {
+ /**
+ * @param {Phaser.Scene} scene
+ * @param {number} x - pixel X
+ * @param {number} y - pixel Y
+ * @param {string} piece - 'BLACK' or 'WHITE'
+ * @param {number} cellSize - board cell size for radius calc
+ * @param {boolean} [animate=true] - play drop animation
+ */
+ constructor(scene, x, y, piece, cellSize, animate = true) {
+ super(scene);
+ this.setPosition(x, y);
+ this.piece = piece;
+ this.radius = cellSize * STONE_RADIUS_RATIO;
+
+ this._drawStone();
+ scene.add.existing(this);
+
+ if (animate) {
+ this.setScale(0);
+ scene.tweens.add({
+ targets: this,
+ scaleX: 1,
+ scaleY: 1,
+ duration: 180,
+ ease: 'Back.easeOut'
+ });
+ }
+ }
+
+ /** @private */
+ _drawStone() {
+ this.clear();
+ const r = this.radius;
+
+ if (this.piece === 'BLACK') {
+ // Dark stone with highlight
+ this.fillStyle(0x222222, 1);
+ this.fillCircle(0, 0, r);
+ // Highlight
+ this.fillStyle(0x555555, 0.4);
+ this.fillCircle(-r * 0.25, -r * 0.25, r * 0.35);
+ } else {
+ // White stone with subtle border
+ this.fillStyle(0xf5f5f5, 1);
+ this.fillCircle(0, 0, r);
+ // Highlight
+ this.fillStyle(0xffffff, 0.6);
+ this.fillCircle(-r * 0.25, -r * 0.25, r * 0.3);
+ // Border
+ this.lineStyle(1, 0x999999, 0.8);
+ this.strokeCircle(0, 0, r);
+ }
+ }
+}
+
+/**
+ * Create a last-move indicator (small red dot).
+ * @param {Phaser.Scene} scene
+ * @param {number} x
+ * @param {number} y
+ * @returns {Phaser.GameObjects.Graphics}
+ */
+export function createLastMoveMarker(scene, x, y) {
+ const g = scene.add.graphics();
+ g.fillStyle(0xe94560, 1);
+ g.fillCircle(x, y, 4);
+ return g;
+}
diff --git a/web-client/src/scenes/boot-scene.js b/web-client/src/scenes/boot-scene.js
new file mode 100644
index 0000000..b785535
--- /dev/null
+++ b/web-client/src/scenes/boot-scene.js
@@ -0,0 +1,32 @@
+import Phaser from 'phaser';
+import { connectionService } from '../services/connection-service.js';
+import { eventBus } from '../services/event-bus.js';
+import { ClientEventCode } from '../config/protocol-constants.js';
+
+/**
+ * BootScene — initial scene that connects to server
+ * and transitions to MenuScene once connected.
+ */
+export class BootScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'BootScene' });
+ }
+
+ create() {
+ this.add.text(400, 380, 'Gomoku', {
+ fontSize: '48px', fontFamily: 'sans-serif', color: '#e94560'
+ }).setOrigin(0.5);
+
+ this.add.text(400, 440, 'Connecting to server...', {
+ fontSize: '18px', fontFamily: 'sans-serif', color: '#888'
+ }).setOrigin(0.5);
+
+ // Connect to server
+ connectionService.connect();
+
+ // Transition to menu once server prompts for nickname
+ eventBus.on(ClientEventCode.NICKNAME_SET, () => {
+ this.scene.start('MenuScene');
+ });
+ }
+}
diff --git a/web-client/src/scenes/game-scene.js b/web-client/src/scenes/game-scene.js
new file mode 100644
index 0000000..319b4ab
--- /dev/null
+++ b/web-client/src/scenes/game-scene.js
@@ -0,0 +1,223 @@
+/**
+ * GameScene — renders the Gomoku board, handles clicks and move events.
+ * Uses Board for grid rendering, Stone for pieces, DOM overlay for HUD.
+ * @module game-scene
+ */
+
+import Phaser from 'phaser';
+import { Board } from '../objects/board.js';
+import { Stone, createLastMoveMarker } from '../objects/stone.js';
+import { eventBus } from '../services/event-bus.js';
+import { gameState } from '../services/game-state-service.js';
+import { connectionService } from '../services/connection-service.js';
+import { ServerEventCode, ClientEventCode } from '../config/protocol-constants.js';
+import { showGameHud, updateTurnIndicator, addMoveToHistory, showGameOver } from '../ui/game-ui.js';
+import { showLobby } from '../ui/menu-ui.js';
+
+export class GameScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'GameScene' });
+ /** @type {Board|null} */
+ this.board = null;
+ /** @type {Stone[]} */
+ this.stones = [];
+ /** @type {Phaser.GameObjects.Graphics|null} */
+ this.lastMarker = null;
+ /** @type {Phaser.GameObjects.Graphics|null} */
+ this.hoverGraphic = null;
+ /** @type {{ row: number, col: number }|null} */
+ this.hoverPos = null;
+
+ // Bind handlers for cleanup
+ this._onMoveSuccess = this._onMoveSuccess.bind(this);
+ this._onGameOver = this._onGameOver.bind(this);
+ this._onClientExit = this._onClientExit.bind(this);
+ this._onGameStarting = this._onGameStarting.bind(this);
+ }
+
+ create() {
+ this.board = new Board(this, 800);
+ this.stones = [];
+ this.lastMarker = null;
+
+ // Hover preview graphic
+ this.hoverGraphic = this.add.graphics();
+ this.hoverGraphic.setDepth(5);
+
+ // Click handler
+ this.input.on('pointerdown', this._handleClick, this);
+ this.input.on('pointermove', this._handleHover, this);
+
+ // Show game HUD overlay
+ showGameHud();
+
+ // Draw any existing moves (rejoin/spectate scenario)
+ for (const move of gameState.moves) {
+ this._placeStone(move.row, move.col, move.piece, false);
+ }
+
+ // Register event handlers
+ eventBus.on(ClientEventCode.GAME_MOVE_SUCCESS, this._onMoveSuccess);
+ eventBus.on(ClientEventCode.GAME_OVER, this._onGameOver);
+ eventBus.on(ClientEventCode.CLIENT_EXIT, this._onClientExit);
+ eventBus.on(ClientEventCode.CLIENT_KICK, this._onClientExit);
+ eventBus.on(ClientEventCode.GAME_STARTING, this._onGameStarting);
+
+ // Play stone sound setup
+ this._audioCtx = null;
+ this.input.once('pointerdown', () => {
+ this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
+ });
+ }
+
+ /**
+ * Place a stone on the board.
+ * @param {number} row
+ * @param {number} col
+ * @param {string} piece - 'BLACK' or 'WHITE'
+ * @param {boolean} [animate=true]
+ * @private
+ */
+ _placeStone(row, col, piece, animate = true) {
+ const x = this.board.gridX(col);
+ const y = this.board.gridY(row);
+ const stone = new Stone(this, x, y, piece, this.board.getCellSize(), animate);
+ stone.setDepth(10);
+ this.stones.push(stone);
+
+ // Update last move marker
+ if (this.lastMarker) this.lastMarker.destroy();
+ this.lastMarker = createLastMoveMarker(this, x, y);
+ this.lastMarker.setDepth(15);
+ }
+
+ /** @private */
+ _playStoneSound() {
+ if (!this._audioCtx) return;
+ try {
+ const osc = this._audioCtx.createOscillator();
+ const gain = this._audioCtx.createGain();
+ osc.connect(gain);
+ gain.connect(this._audioCtx.destination);
+ osc.type = 'sine';
+ osc.frequency.value = 800;
+ gain.gain.setValueAtTime(0.12, this._audioCtx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this._audioCtx.currentTime + 0.08);
+ osc.start();
+ osc.stop(this._audioCtx.currentTime + 0.08);
+ } catch (_) { /* ignore */ }
+ }
+
+ /**
+ * Handle board click — send move to server.
+ * @param {Phaser.Input.Pointer} pointer
+ * @private
+ */
+ _handleClick(pointer) {
+ if (gameState.isSpectating || !gameState.isMyTurn()) return;
+ const pos = this.board.pixelToGrid(pointer.x, pointer.y);
+ if (!pos) return;
+ if (gameState.isOccupied(pos.row, pos.col)) return;
+ connectionService.send(ServerEventCode.GAME_MOVE, { row: pos.row, col: pos.col });
+ }
+
+ /**
+ * Handle hover — show preview stone.
+ * @param {Phaser.Input.Pointer} pointer
+ * @private
+ */
+ _handleHover(pointer) {
+ this.hoverGraphic.clear();
+ if (gameState.isSpectating || !gameState.isMyTurn()) return;
+ const pos = this.board.pixelToGrid(pointer.x, pointer.y);
+ if (!pos || gameState.isOccupied(pos.row, pos.col)) {
+ this.hoverPos = null;
+ return;
+ }
+ if (this.hoverPos && this.hoverPos.row === pos.row && this.hoverPos.col === pos.col) return;
+ this.hoverPos = pos;
+ const x = this.board.gridX(pos.col);
+ const y = this.board.gridY(pos.row);
+ const color = gameState.isBlack ? 0x222222 : 0xf5f5f5;
+ this.hoverGraphic.fillStyle(color, 0.35);
+ this.hoverGraphic.fillCircle(x, y, this.board.getCellSize() * 0.43);
+ }
+
+ /**
+ * Handle successful move from server.
+ * @param {{ row: number, col: number, piece: string, playerNickname: string }} data
+ * @private
+ */
+ _onMoveSuccess(data) {
+ this._placeStone(data.row, data.col, data.piece, true);
+ this._playStoneSound();
+ addMoveToHistory(data);
+ updateTurnIndicator();
+ this.hoverGraphic.clear();
+ }
+
+ /**
+ * Handle game over.
+ * @param {{ result: string, winnerNickname: string }} data
+ * @private
+ */
+ _onGameOver(data) {
+ showGameOver(data);
+ // Play win/lose sound
+ if (!this._audioCtx) return;
+ const isWin = data.winnerNickname === gameState.nickname;
+ const freq = isWin ? [523, 659, 784] : [400, 300];
+ freq.forEach((f, i) => {
+ setTimeout(() => {
+ try {
+ const osc = this._audioCtx.createOscillator();
+ const gain = this._audioCtx.createGain();
+ osc.connect(gain);
+ gain.connect(this._audioCtx.destination);
+ osc.type = isWin ? 'sine' : 'triangle';
+ osc.frequency.value = f;
+ gain.gain.setValueAtTime(0.12, this._audioCtx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, this._audioCtx.currentTime + 0.2);
+ osc.start();
+ osc.stop(this._audioCtx.currentTime + 0.2);
+ } catch (_) { /* ignore */ }
+ }, i * 150);
+ });
+ }
+
+ /**
+ * Handle client exit — return to menu.
+ * @private
+ */
+ _onClientExit() {
+ this._cleanup();
+ this.scene.start('MenuScene');
+ showLobby();
+ }
+
+ /**
+ * Handle rematch — new game starting while already in GameScene.
+ * @private
+ */
+ _onGameStarting() {
+ // Clear existing stones and redraw board
+ this.stones.forEach(s => s.destroy());
+ this.stones = [];
+ if (this.lastMarker) { this.lastMarker.destroy(); this.lastMarker = null; }
+ this.hoverGraphic.clear();
+ showGameHud();
+ }
+
+ /** @private */
+ _cleanup() {
+ eventBus.off(ClientEventCode.GAME_MOVE_SUCCESS, this._onMoveSuccess);
+ eventBus.off(ClientEventCode.GAME_OVER, this._onGameOver);
+ eventBus.off(ClientEventCode.CLIENT_EXIT, this._onClientExit);
+ eventBus.off(ClientEventCode.CLIENT_KICK, this._onClientExit);
+ eventBus.off(ClientEventCode.GAME_STARTING, this._onGameStarting);
+ }
+
+ shutdown() {
+ this._cleanup();
+ }
+}
diff --git a/web-client/src/scenes/menu-scene.js b/web-client/src/scenes/menu-scene.js
new file mode 100644
index 0000000..ed63ced
--- /dev/null
+++ b/web-client/src/scenes/menu-scene.js
@@ -0,0 +1,48 @@
+/**
+ * MenuScene — shows DOM overlay menus (nickname, lobby, PVP/PVE).
+ * Phaser canvas shows a subtle background; all interaction is via DOM.
+ * @module menu-scene
+ */
+
+import Phaser from 'phaser';
+import { eventBus } from '../services/event-bus.js';
+import { ClientEventCode } from '../config/protocol-constants.js';
+import { showNicknameScreen, hideOverlay } from '../ui/menu-ui.js';
+
+export class MenuScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'MenuScene' });
+ }
+
+ create() {
+ // Subtle background
+ this.cameras.main.setBackgroundColor('#1a1a2e');
+
+ // Grid pattern background decoration
+ const g = this.add.graphics();
+ g.lineStyle(1, 0x16213e, 0.4);
+ for (let i = 0; i < 800; i += 40) {
+ g.lineBetween(i, 0, i, 800);
+ g.lineBetween(0, i, 800, i);
+ }
+
+ // Show nickname entry
+ showNicknameScreen();
+
+ // Transition to GameScene when game starts
+ eventBus.on(ClientEventCode.GAME_STARTING, this._onGameStarting.bind(this));
+ }
+
+ /**
+ * Handle game starting — transition to GameScene.
+ * @private
+ */
+ _onGameStarting() {
+ hideOverlay();
+ this.scene.start('GameScene');
+ }
+
+ shutdown() {
+ eventBus.off(ClientEventCode.GAME_STARTING, this._onGameStarting.bind(this));
+ }
+}
diff --git a/web-client/src/services/connection-service.js b/web-client/src/services/connection-service.js
new file mode 100644
index 0000000..ef7ea87
--- /dev/null
+++ b/web-client/src/services/connection-service.js
@@ -0,0 +1,144 @@
+/**
+ * WebSocket connection service.
+ * Wraps browser WebSocket with heartbeat, auto-reconnect, and message parsing.
+ * @module connection-service
+ */
+
+import { eventBus } from './event-bus.js';
+import { ServerEventCode } from '../config/protocol-constants.js';
+
+/** Default server WebSocket URL */
+const DEFAULT_WS_URL = 'ws://localhost:1025/ratel';
+
+class ConnectionService {
+ constructor() {
+ /** @type {WebSocket|null} */
+ this._ws = null;
+ /** @type {number|null} */
+ this._heartbeatTimer = null;
+ /** @type {number} */
+ this._reconnectDelay = 1000;
+ /** @type {boolean} */
+ this._intentionalClose = false;
+ }
+
+ /**
+ * Connect to the game server.
+ * @param {string} [url] - WebSocket URL, defaults to same-origin or localhost
+ */
+ connect(url) {
+ const wsUrl = url || this._resolveUrl();
+ this._intentionalClose = false;
+
+ try {
+ this._ws = new WebSocket(wsUrl);
+ } catch (e) {
+ console.error('WebSocket creation failed:', e);
+ return;
+ }
+
+ this._ws.onopen = () => {
+ console.log('WebSocket connected');
+ this._reconnectDelay = 1000;
+ this._startHeartbeat();
+ eventBus.emit('ws:connected', null);
+ };
+
+ this._ws.onmessage = (event) => this._onMessage(event);
+
+ this._ws.onclose = () => {
+ console.log('WebSocket disconnected');
+ this._stopHeartbeat();
+ eventBus.emit('ws:disconnected', null);
+ if (!this._intentionalClose) this._scheduleReconnect(wsUrl);
+ };
+
+ this._ws.onerror = (err) => {
+ console.error('WebSocket error:', err);
+ };
+ }
+
+ /**
+ * Send a message to the server.
+ * @param {string} code - ServerEventCode value
+ * @param {string|object} [data] - payload (objects are JSON.stringify'd)
+ */
+ send(code, data) {
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
+ console.warn('WebSocket not connected, cannot send:', code);
+ return;
+ }
+ const dataStr = (data === undefined || data === null) ? ''
+ : (typeof data === 'string') ? data
+ : JSON.stringify(data);
+ this._ws.send(JSON.stringify({ code, data: dataStr, info: '' }));
+ }
+
+ /** Close the connection intentionally. */
+ disconnect() {
+ this._intentionalClose = true;
+ this._stopHeartbeat();
+ if (this._ws) this._ws.close();
+ }
+
+ /**
+ * Parse incoming WebSocket message and emit via event bus.
+ * @param {MessageEvent} event
+ * @private
+ */
+ _onMessage(event) {
+ try {
+ const msg = JSON.parse(event.data);
+ let data = msg.data;
+ if (data && typeof data === 'string') {
+ try { data = JSON.parse(data); } catch (_) { /* keep as string */ }
+ }
+ eventBus.emit(msg.code, data);
+ } catch (e) {
+ console.error('Message parse error:', e, event.data);
+ }
+ }
+
+ /** @private */
+ _startHeartbeat() {
+ this._stopHeartbeat();
+ this._heartbeatTimer = setInterval(() => {
+ this.send(ServerEventCode.HEARTBEAT, '');
+ }, 50000);
+ }
+
+ /** @private */
+ _stopHeartbeat() {
+ if (this._heartbeatTimer) {
+ clearInterval(this._heartbeatTimer);
+ this._heartbeatTimer = null;
+ }
+ }
+
+ /**
+ * Reconnect with exponential backoff (max 30s).
+ * @param {string} url
+ * @private
+ */
+ _scheduleReconnect(url) {
+ console.log(`Reconnecting in ${this._reconnectDelay}ms...`);
+ setTimeout(() => this.connect(url), this._reconnectDelay);
+ this._reconnectDelay = Math.min(this._reconnectDelay * 2, 30000);
+ }
+
+ /**
+ * Derive WebSocket URL from current page location or fall back to default.
+ * @returns {string}
+ * @private
+ */
+ _resolveUrl() {
+ if (typeof window !== 'undefined' && window.location.hostname !== 'localhost'
+ && window.location.hostname !== '127.0.0.1') {
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ return `${proto}//${window.location.hostname}:1025/ratel`;
+ }
+ return DEFAULT_WS_URL;
+ }
+}
+
+export const connectionService = new ConnectionService();
diff --git a/web-client/src/services/event-bus.js b/web-client/src/services/event-bus.js
new file mode 100644
index 0000000..88325f1
--- /dev/null
+++ b/web-client/src/services/event-bus.js
@@ -0,0 +1,48 @@
+/**
+ * Simple pub/sub event bus for decoupling WebSocket from scenes.
+ * @module event-bus
+ */
+
+class EventBus {
+ constructor() {
+ /** @type {Map>} */
+ this._listeners = new Map();
+ }
+
+ /**
+ * Subscribe to an event.
+ * @param {string} event - event name (e.g. ClientEventCode value)
+ * @param {function} callback - handler function
+ */
+ on(event, callback) {
+ if (!this._listeners.has(event)) {
+ this._listeners.set(event, new Set());
+ }
+ this._listeners.get(event).add(callback);
+ }
+
+ /**
+ * Unsubscribe from an event.
+ * @param {string} event
+ * @param {function} callback
+ */
+ off(event, callback) {
+ const set = this._listeners.get(event);
+ if (set) set.delete(callback);
+ }
+
+ /**
+ * Emit an event to all subscribers.
+ * @param {string} event
+ * @param {*} data - parsed event data
+ */
+ emit(event, data) {
+ const set = this._listeners.get(event);
+ if (!set) return;
+ for (const cb of set) {
+ try { cb(data); } catch (e) { console.error(`EventBus error [${event}]:`, e); }
+ }
+ }
+}
+
+export const eventBus = new EventBus();
diff --git a/web-client/src/services/game-state-service.js b/web-client/src/services/game-state-service.js
new file mode 100644
index 0000000..38424b5
--- /dev/null
+++ b/web-client/src/services/game-state-service.js
@@ -0,0 +1,112 @@
+/**
+ * Game state service — stores all client-side game state.
+ * Pure data container with reset methods, no game logic.
+ * @module game-state-service
+ */
+
+import { eventBus } from './event-bus.js';
+import { ClientEventCode } from '../config/protocol-constants.js';
+
+/**
+ * @typedef {Object} MoveEntry
+ * @property {number} row
+ * @property {number} col
+ * @property {string} piece - 'BLACK' or 'WHITE'
+ * @property {string} playerNickname
+ * @property {number} playerId
+ */
+
+class GameStateService {
+ constructor() {
+ /** @type {number|null} */
+ this.clientId = null;
+ /** @type {string} */
+ this.nickname = '';
+ /** @type {number|null} */
+ this.roomId = null;
+ /** @type {boolean} */
+ this.isBlack = false;
+ /** @type {boolean} */
+ this.isSpectating = false;
+ /** @type {string} - 'BLACK' or 'WHITE' */
+ this.currentTurn = 'BLACK';
+ /** @type {MoveEntry[]} */
+ this.moves = [];
+ /** @type {number} */
+ this.boardSize = 15;
+ /** @type {string} */
+ this.blackPlayerNickname = '';
+ /** @type {string} */
+ this.whitePlayerNickname = '';
+ /** @type {number} */
+ this.blackPlayerId = -1;
+ /** @type {number} */
+ this.whitePlayerId = -1;
+
+ this._registerHandlers();
+ }
+
+ /** @returns {boolean} */
+ isMyTurn() {
+ if (this.isSpectating) return false;
+ const myPiece = this.isBlack ? 'BLACK' : 'WHITE';
+ return this.currentTurn === myPiece;
+ }
+
+ /** Reset room and game state (on exit/kick). */
+ reset() {
+ this.roomId = null;
+ this.isSpectating = false;
+ this.resetBoard();
+ }
+
+ /** Reset board state (on new game / rematch). */
+ resetBoard() {
+ this.moves = [];
+ this.currentTurn = 'BLACK';
+ this.isBlack = false;
+ this.blackPlayerNickname = '';
+ this.whitePlayerNickname = '';
+ this.blackPlayerId = -1;
+ this.whitePlayerId = -1;
+ }
+
+ /**
+ * Check if a board position is occupied.
+ * @param {number} row
+ * @param {number} col
+ * @returns {boolean}
+ */
+ isOccupied(row, col) {
+ return this.moves.some(m => m.row === row && m.col === col);
+ }
+
+ /** @private */
+ _registerHandlers() {
+ eventBus.on(ClientEventCode.CLIENT_CONNECT, (data) => {
+ this.clientId = parseInt(data);
+ });
+
+ eventBus.on(ClientEventCode.GAME_STARTING, (data) => {
+ this.resetBoard();
+ this.roomId = data.roomId;
+ this.blackPlayerId = data.blackPlayerId;
+ this.blackPlayerNickname = data.blackPlayerNickname;
+ this.whitePlayerId = data.whitePlayerId;
+ this.whitePlayerNickname = data.whitePlayerNickname;
+ this.boardSize = data.boardSize || 15;
+ this.isBlack = (this.clientId === data.blackPlayerId);
+ this.currentTurn = 'BLACK';
+ });
+
+ eventBus.on(ClientEventCode.GAME_MOVE_SUCCESS, (data) => {
+ this.moves.push(data);
+ this.currentTurn = (data.piece === 'BLACK') ? 'WHITE' : 'BLACK';
+ });
+
+ eventBus.on(ClientEventCode.CLIENT_EXIT, () => this.reset());
+ eventBus.on(ClientEventCode.CLIENT_KICK, () => this.reset());
+ }
+}
+
+export const gameState = new GameStateService();
diff --git a/web-client/src/ui/game-ui.js b/web-client/src/ui/game-ui.js
new file mode 100644
index 0000000..1eb3684
--- /dev/null
+++ b/web-client/src/ui/game-ui.js
@@ -0,0 +1,145 @@
+/**
+ * Game UI — DOM overlay for player info, move history, game over, and toasts.
+ * Rendered on top of the Phaser canvas during gameplay.
+ * @module game-ui
+ */
+
+import { connectionService } from '../services/connection-service.js';
+import { eventBus } from '../services/event-bus.js';
+import { gameState } from '../services/game-state-service.js';
+import { ServerEventCode, ClientEventCode } from '../config/protocol-constants.js';
+
+const overlay = () => document.getElementById('ui-overlay');
+
+/**
+ * Show the game HUD (player panels + move history + controls).
+ */
+export function showGameHud() {
+ const el = overlay();
+ el.innerHTML = `
+
+
+
+
+ ${gameState.blackPlayerNickname}
+
+
+
VS
+
+
+ ${gameState.whitePlayerNickname}
+
+
+
+
+
+
+
+
+
+ `;
+ el.style.display = 'flex';
+ document.getElementById('btn-exit-game').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.CLIENT_EXIT, '');
+ });
+ updateTurnIndicator();
+}
+
+/** Update the turn indicator dots. */
+export function updateTurnIndicator() {
+ const blackDot = document.getElementById('turn-black');
+ const whiteDot = document.getElementById('turn-white');
+ if (!blackDot || !whiteDot) return;
+ blackDot.classList.toggle('active', gameState.currentTurn === 'BLACK');
+ whiteDot.classList.toggle('active', gameState.currentTurn === 'WHITE');
+}
+
+/**
+ * Add a move entry to the history panel.
+ * @param {{ row: number, col: number, piece: string, playerNickname: string }} data
+ */
+export function addMoveToHistory(data) {
+ const list = document.getElementById('move-list');
+ if (!list) return;
+ const n = gameState.moves.length;
+ const col = String.fromCharCode(65 + data.col);
+ const row = data.row + 1;
+ const div = document.createElement('div');
+ div.className = `move-entry ${data.piece.toLowerCase()}`;
+ div.textContent = `#${n} ${data.piece === 'BLACK' ? '●' : '○'} ${col}${row}`;
+ list.appendChild(div);
+ list.scrollTop = list.scrollHeight;
+}
+
+/**
+ * Show the game over overlay.
+ * @param {{ result: string, winnerNickname: string }} data
+ */
+export function showGameOver(data) {
+ let resultText, resultClass;
+ if (data.result === 'DRAW') {
+ resultText = 'Draw!';
+ resultClass = 'draw';
+ } else if (data.winnerNickname === gameState.nickname) {
+ resultText = 'You Win!';
+ resultClass = 'win';
+ } else {
+ resultText = 'You Lose!';
+ resultClass = 'lose';
+ }
+
+ const el = overlay();
+ el.innerHTML += `
+
+
+
${resultText}
+ ${data.winnerNickname ? `
Winner: ${data.winnerNickname}
` : ''}
+
+
+
+
+
+
+ `;
+ document.getElementById('btn-rematch').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.GAME_READY, '');
+ });
+ document.getElementById('btn-exit-lobby').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.CLIENT_EXIT, '');
+ });
+}
+
+/**
+ * Show a toast notification.
+ * @param {string} message
+ * @param {'info'|'error'|'success'} [type='info']
+ */
+export function showToast(message, type = 'info') {
+ let container = document.getElementById('toast-container');
+ if (!container) {
+ container = document.createElement('div');
+ container.id = 'toast-container';
+ document.body.appendChild(container);
+ }
+ const toast = document.createElement('div');
+ toast.className = `toast toast-${type}`;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(() => {
+ toast.classList.add('toast-exit');
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+}
+
+// Register error toasts
+eventBus.on(ClientEventCode.ROOM_JOIN_FAIL_FULL, () => showToast('Room is full', 'error'));
+eventBus.on(ClientEventCode.ROOM_JOIN_FAIL_INEXIST, () => showToast('Room not found', 'error'));
+eventBus.on(ClientEventCode.GAME_MOVE_NOT_YOUR_TURN, () => showToast('Not your turn', 'error'));
+eventBus.on(ClientEventCode.GAME_MOVE_OCCUPIED, () => showToast('Position occupied', 'error'));
+eventBus.on(ClientEventCode.GAME_MOVE_OUT_OF_BOUNDS, () => showToast('Out of bounds', 'error'));
+eventBus.on(ClientEventCode.GAME_MOVE_INVALID, () => showToast('Invalid move', 'error'));
+eventBus.on(ClientEventCode.PVE_DIFFICULTY_NOT_SUPPORT, () => showToast('Difficulty not supported', 'error'));
+eventBus.on('ws:disconnected', () => showToast('Connection lost. Reconnecting...', 'error'));
diff --git a/web-client/src/ui/menu-ui.js b/web-client/src/ui/menu-ui.js
new file mode 100644
index 0000000..468977b
--- /dev/null
+++ b/web-client/src/ui/menu-ui.js
@@ -0,0 +1,178 @@
+/**
+ * Menu UI — DOM overlay for nickname, lobby, PVP/PVE menus, room list, waiting room.
+ * @module menu-ui
+ */
+
+import { connectionService } from '../services/connection-service.js';
+import { eventBus } from '../services/event-bus.js';
+import { gameState } from '../services/game-state-service.js';
+import { ServerEventCode, ClientEventCode } from '../config/protocol-constants.js';
+
+const overlay = () => document.getElementById('ui-overlay');
+
+/**
+ * Show a screen in the overlay.
+ * @param {string} html
+ */
+function showOverlay(html) {
+ const el = overlay();
+ el.innerHTML = html;
+ el.style.display = 'flex';
+}
+
+/** Hide the overlay. */
+export function hideOverlay() {
+ const el = overlay();
+ el.innerHTML = '';
+ el.style.display = 'none';
+}
+
+/** Show nickname entry screen. */
+export function showNicknameScreen() {
+ showOverlay(`
+
+ `);
+ const input = document.getElementById('input-nickname');
+ const btn = document.getElementById('btn-play');
+ const submit = () => {
+ const name = input.value.trim();
+ if (!name) return;
+ gameState.nickname = name;
+ connectionService.send(ServerEventCode.NICKNAME_SET, name);
+ };
+ btn.addEventListener('click', submit);
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
+ input.focus();
+}
+
+/** Show lobby (main menu). */
+export function showLobby() {
+ showOverlay(`
+
+ `);
+ document.getElementById('btn-pvp').addEventListener('click', showPvpMenu);
+ document.getElementById('btn-pve').addEventListener('click', showPveMenu);
+}
+
+/** Show PVP submenu. */
+function showPvpMenu() {
+ showOverlay(`
+
+ `);
+ document.getElementById('btn-create').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.ROOM_CREATE, '');
+ });
+ document.getElementById('btn-rooms').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.GET_ROOMS, '');
+ });
+ document.getElementById('btn-back').addEventListener('click', showLobby);
+}
+
+/** Show PVE difficulty selection. */
+function showPveMenu() {
+ showOverlay(`
+
+ `);
+ document.getElementById('btn-easy').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.ROOM_CREATE_PVE, '1');
+ });
+ document.getElementById('btn-medium').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.ROOM_CREATE_PVE, '2');
+ });
+ document.getElementById('btn-hard').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.ROOM_CREATE_PVE, '3');
+ });
+ document.getElementById('btn-back').addEventListener('click', showLobby);
+}
+
+/**
+ * Show room list.
+ * @param {Array} rooms
+ */
+export function showRoomList(rooms) {
+ const rows = Array.isArray(rooms) ? rooms : [];
+ const tableRows = rows.length === 0
+ ? '| No rooms available |
'
+ : rows.map(r => `
+
+ | ${r.roomId || r.id} |
+ ${r.roomOwner || ''} |
+ ${r.roomClientCount || 0}/2 |
+
+
+
+ |
+
`).join('');
+
+ showOverlay(`
+
+ `);
+ overlay().querySelectorAll('[data-join]').forEach(btn => {
+ btn.addEventListener('click', () => connectionService.send(ServerEventCode.ROOM_JOIN, btn.dataset.join));
+ });
+ overlay().querySelectorAll('[data-watch]').forEach(btn => {
+ btn.addEventListener('click', () => connectionService.send(ServerEventCode.GAME_WATCH, btn.dataset.watch));
+ });
+ document.getElementById('btn-refresh').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.GET_ROOMS, '');
+ });
+ document.getElementById('btn-back').addEventListener('click', showPvpMenu);
+}
+
+/** Show waiting room. */
+export function showWaitingRoom() {
+ showOverlay(`
+
+ `);
+ document.getElementById('btn-leave').addEventListener('click', () => {
+ connectionService.send(ServerEventCode.CLIENT_EXIT, '');
+ });
+}
+
+// Register server event handlers for menu navigation
+eventBus.on(ClientEventCode.SHOW_OPTIONS, showLobby);
+eventBus.on(ClientEventCode.SHOW_ROOMS, showRoomList);
+eventBus.on(ClientEventCode.ROOM_CREATE_SUCCESS, (data) => {
+ gameState.roomId = data.id;
+ showWaitingRoom();
+});
+eventBus.on(ClientEventCode.CLIENT_EXIT, showLobby);
+eventBus.on(ClientEventCode.CLIENT_KICK, showLobby);
diff --git a/web-client/vite.config.js b/web-client/vite.config.js
new file mode 100644
index 0000000..dd33815
--- /dev/null
+++ b/web-client/vite.config.js
@@ -0,0 +1,5 @@
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ server: { port: 5173 }
+});