From 22bb9c13715a4a7bdc0966a2466fef55924663f3 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 10 Apr 2026 09:46:06 +0700 Subject: [PATCH] feat: add Phaser 3 web client with Vite scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate web-client/ directory with Phaser 3 + Vite + vanilla JS: - Services: event bus, WebSocket connection (heartbeat, reconnect), game state, protocol constants matching server enums - Scenes: BootScene (connect), MenuScene (DOM overlay), GameScene (canvas board with grid, stones, hover, click-to-move, animations) - Objects: Board (wood grid, star points, labels), Stone (gradient circles with drop tween animation) - UI: DOM overlays for nickname, lobby, PVP/PVE menus, room list, waiting room, game HUD, game over, toast notifications - Audio: Web Audio API stone click + win/lose tones - Full game flow: nickname → lobby → create/join/PVE → play → game over --- .claude/settings.local.json | 3 +- .gitignore | 6 +- web-client/index.html | 167 +++ web-client/package-lock.json | 1122 +++++++++++++++++ web-client/package.json | 16 + web-client/src/config/game-config.js | 18 + web-client/src/config/protocol-constants.js | 48 + web-client/src/main.js | 7 + web-client/src/objects/board.js | 108 ++ web-client/src/objects/stone.js | 79 ++ web-client/src/scenes/boot-scene.js | 32 + web-client/src/scenes/game-scene.js | 223 ++++ web-client/src/scenes/menu-scene.js | 48 + web-client/src/services/connection-service.js | 144 +++ web-client/src/services/event-bus.js | 48 + web-client/src/services/game-state-service.js | 112 ++ web-client/src/ui/game-ui.js | 145 +++ web-client/src/ui/menu-ui.js | 178 +++ web-client/vite.config.js | 5 + 19 files changed, 2507 insertions(+), 2 deletions(-) create mode 100644 web-client/index.html create mode 100644 web-client/package-lock.json create mode 100644 web-client/package.json create mode 100644 web-client/src/config/game-config.js create mode 100644 web-client/src/config/protocol-constants.js create mode 100644 web-client/src/main.js create mode 100644 web-client/src/objects/board.js create mode 100644 web-client/src/objects/stone.js create mode 100644 web-client/src/scenes/boot-scene.js create mode 100644 web-client/src/scenes/game-scene.js create mode 100644 web-client/src/scenes/menu-scene.js create mode 100644 web-client/src/services/connection-service.js create mode 100644 web-client/src/services/event-bus.js create mode 100644 web-client/src/services/game-state-service.js create mode 100644 web-client/src/ui/game-ui.js create mode 100644 web-client/src/ui/menu-ui.js create mode 100644 web-client/vite.config.js 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} + +
+
+
+
Moves
+
+
+
+ + +
+
+ `; + 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 } +});