diff --git a/.gitignore b/.gitignore index aa0926a..cc816ba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .env *.log +.idea/ +.serena/ diff --git a/README.md b/README.md index 265ff47..2befc42 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,63 @@ npm run dev Ouvrir `http://localhost:5173` +## Tests + +Tests unitaires du moteur de jeu (serveur), avec le test runner natif de Node.js : + +```bash +cd server +npm test # exécution unique +npm run test:watch # mode watch +``` + ## Règles -- 2 à 4 joueurs par partie -- Chaque tour, chaque joueur choisit **simultanément** une carte -- Les cartes sont révélées et résolues en même temps -- **Types de cartes** : - - **Royaume** : points fixes - - **Armée** : vole des points à un adversaire - - **Commerce** : bonus si associé à du Royaume - - **Magie** : effets spéciaux (protection, doublement, pioche) -- La partie dure 10 tours, le plus de points gagne +### Principe général + +- 2 à 4 joueurs par partie, chacun avec son propre deck des mêmes 12 personnages (seule la couleur d'équipe change). +- Chaque joueur commence avec une main de 4 cartes ; le reste du deck (8 cartes) est pioché une carte par tour. +- La partie dure **12 tours**. Elle se termine avant si tous les joueurs n'ont plus ni carte en main ni carte à piocher. +- Chaque tour, tous les joueurs choisissent **simultanément** une carte (cachée), puis les cartes sont révélées et résolues en même temps. + +### Les 4 jauges + +Chaque personnage possède 4 attributs (valeurs de 1 à 6), qui correspondent à 4 jauges cumulées par le joueur pendant toute la partie : + +| Jauge | Icône | +|---|---| +| Nourriture | 🟡 | +| Guerre | 🔴 | +| Santé | 🟢 | +| Diplomatie | 🔵 | + +### Résolution d'un tour + +Pour chaque attribut, un joueur compare la valeur de sa carte jouée à celle de son **voisin de gauche** (ordre des joueurs dans la partie, en boucle) : + +``` +gain = max(0, ma_valeur - valeur_du_voisin_de_gauche) +``` + +Le gain (jamais négatif) est ajouté à la jauge correspondante du joueur. Le score total d'un joueur est la somme de ses 4 jauges. + +### Les 12 personnages + +Reine, Prince, Boucher, Médecin, Apothicaire, Fou du Roi, Assassin, Paysan, Bourreau, Chevalier, Marchand, Évêque — chacun avec un profil d'attributs différent (ex : le Paysan est fort en Nourriture mais faible en Diplomatie, l'Assassin est très fort en Guerre mais faible partout ailleurs). + +### Objets achetables + +Pendant la phase de sélection, un joueur peut dépenser des points d'une jauge pour acheter un objet, qui procure un **effet permanent** pour le reste de la partie tant qu'il le possède. Chaque objet coûte 3 points et n'existe qu'en **un seul exemplaire** pour toute la partie (premier arrivé, premier servi) : + +| Objet | Jauge liée | Effet | +|---|---|---| +| Trébuchet | 🔴 Guerre | Double le gain de Guerre à chaque tour | +| Charrue | 🟡 Nourriture | Double le gain de Nourriture à chaque tour | +| Trousse du Médecin | 🟢 Santé | Garantit +2 en Santé à chaque tour, quel que soit le résultat | +| Sceau Royal | 🔵 Diplomatie | Copie le meilleur gain de Diplomatie parmi tous les autres joueurs | + +En cas de conflit (plusieurs joueurs achètent le même objet le même tour), c'est le joueur le plus proche du **"premier joueur"** du tour (rôle qui tourne entre les joueurs à chaque tour) qui l'obtient ; les autres conservent leurs points. + +### Fin de partie + +Le joueur avec le score total (somme des 4 jauges) le plus élevé après 12 tours remporte la partie. diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..207f8d5 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,1461 @@ +{ + "name": "regnum-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "regnum-client", + "version": "0.1.0", + "dependencies": { + "phaser": "^3.80.1", + "pinia": "^2.1.0", + "socket.io-client": "^4.7.5", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "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/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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/client/public/cards/apoticaire.png b/client/public/cards/apoticaire.png index e950340..20bdbaa 100644 Binary files a/client/public/cards/apoticaire.png and b/client/public/cards/apoticaire.png differ diff --git a/client/public/cards/assassin.png b/client/public/cards/assassin.png index 2896fac..5bb0726 100644 Binary files a/client/public/cards/assassin.png and b/client/public/cards/assassin.png differ diff --git a/client/public/cards/boucher.png b/client/public/cards/boucher.png index d4c0743..7552adc 100644 Binary files a/client/public/cards/boucher.png and b/client/public/cards/boucher.png differ diff --git a/client/public/cards/bourreau.png b/client/public/cards/bourreau.png index b98e1c3..dff4982 100644 Binary files a/client/public/cards/bourreau.png and b/client/public/cards/bourreau.png differ diff --git a/client/public/cards/chevalier.png b/client/public/cards/chevalier.png index 9425f60..ec2f60b 100644 Binary files a/client/public/cards/chevalier.png and b/client/public/cards/chevalier.png differ diff --git a/client/public/cards/eveque.png b/client/public/cards/eveque.png index ddc0cbd..d71b635 100644 Binary files a/client/public/cards/eveque.png and b/client/public/cards/eveque.png differ diff --git a/client/public/cards/fou_du_roi.png b/client/public/cards/fou_du_roi.png index f3ae3ca..df5f743 100644 Binary files a/client/public/cards/fou_du_roi.png and b/client/public/cards/fou_du_roi.png differ diff --git a/client/public/cards/marchand.png b/client/public/cards/marchand.png index ebd8814..b31980c 100644 Binary files a/client/public/cards/marchand.png and b/client/public/cards/marchand.png differ diff --git a/client/public/cards/medecin.png b/client/public/cards/medecin.png index 446acbe..14913eb 100644 Binary files a/client/public/cards/medecin.png and b/client/public/cards/medecin.png differ diff --git a/client/public/cards/paysan.png b/client/public/cards/paysan.png index d454a17..1616b15 100644 Binary files a/client/public/cards/paysan.png and b/client/public/cards/paysan.png differ diff --git a/client/public/cards/prince.png b/client/public/cards/prince.png index 9201402..f519563 100644 Binary files a/client/public/cards/prince.png and b/client/public/cards/prince.png differ diff --git a/client/public/cards/reine.png b/client/public/cards/reine.png index c77f4df..2593146 100644 Binary files a/client/public/cards/reine.png and b/client/public/cards/reine.png differ diff --git a/client/public/items/charrue.png b/client/public/items/charrue.png new file mode 100644 index 0000000..d24e130 Binary files /dev/null and b/client/public/items/charrue.png differ diff --git a/client/public/items/sceau.png b/client/public/items/sceau.png new file mode 100644 index 0000000..71014d2 Binary files /dev/null and b/client/public/items/sceau.png differ diff --git a/client/public/items/trebuchet.png b/client/public/items/trebuchet.png new file mode 100644 index 0000000..40cbc3e Binary files /dev/null and b/client/public/items/trebuchet.png differ diff --git a/client/public/items/trousse.png b/client/public/items/trousse.png new file mode 100644 index 0000000..f67c535 Binary files /dev/null and b/client/public/items/trousse.png differ diff --git a/client/src/game/items.js b/client/src/game/items.js new file mode 100644 index 0000000..35d7c85 --- /dev/null +++ b/client/src/game/items.js @@ -0,0 +1,46 @@ +/** + * Regnum - Définition des objets achetables (miroir du serveur, pour l'affichage) + * + * Chaque objet est lié à un attribut, coûte des points de cette jauge à + * l'achat, et procure un effet permanent. Un seul exemplaire existe par + * partie (stock unique) — voir server/src/game/items.js pour la logique. + */ + +export const ITEMS = [ + { + id: 'trebuchet', + name: 'Trébuchet', + attr: 'war', + cost: 3, + image: 'trebuchet.png', + description: 'Double le gain de Guerre obtenu à chaque tour.' + }, + { + id: 'plow', + name: 'Charrue', + attr: 'food', + cost: 3, + image: 'charrue.png', + description: 'Double le gain de Nourriture obtenu à chaque tour.' + }, + { + id: 'medkit', + name: 'Trousse du Médecin', + attr: 'health', + cost: 3, + image: 'trousse.png', + description: 'Garantit +2 en Santé à chaque tour, quel que soit le résultat.' + }, + { + id: 'royal_seal', + name: 'Sceau Royal', + attr: 'diplo', + cost: 3, + image: 'sceau.png', + description: 'Copie à chaque tour le meilleur gain de Diplomatie de la partie.' + } +] + +export function getItemById(id) { + return ITEMS.find(i => i.id === id) ?? null +} diff --git a/client/src/game/objects/CardObject.js b/client/src/game/objects/CardObject.js index 7b4372f..2a63b02 100644 --- a/client/src/game/objects/CardObject.js +++ b/client/src/game/objects/CardObject.js @@ -1,18 +1,21 @@ import Phaser from 'phaser' +import { CARD_W, CARD_H, RADIUS } from './cardLayout' -const ATTR = { - food: { color: 0xf0c030, label: 'NOU' }, - war: { color: 0xdd2222, label: 'GUE' }, - health: { color: 0x22aa44, label: 'SAN' }, - diplo: { color: 0x3399ee, label: 'DIP' } +const ATTR_COLORS = { + food: '#f0c030', + war: '#ff5555', + health: '#4ade80', + diplo: '#60a5fa' } -const CARD_W = 130 -const CARD_H = 190 -const RADIUS = 10 -const BADGE = 30 -const TITLE_H = 26 +// Marge depuis les bords pour les chiffres discrets dans les coins +const CORNER_MARGIN = 12 +/** + * Carte personnage épurée : portrait plein cadre (quasi sans superposition), + * nom en simple texte en bas, et les 4 attributs affichés en petits chiffres + * discrets dans les coins (sans cercle/badge volumineux ni bordure épaisse). + */ export class CardObject extends Phaser.GameObjects.Container { constructor(scene, x, y, cardData) { super(scene, x, y) @@ -28,43 +31,39 @@ export class CardObject extends Phaser.GameObjects.Container { const textureKey = `card-${this.cardData.id}` const hasTexture = this.scene.textures.exists(textureKey) - // ── 1. Ombre ───────────────────────────────────────────────────────────── + // ── 1. Ombre légère ───────────────────────────────────────────────────── const shadow = this.scene.add.graphics() - shadow.fillStyle(0x000000, 0.5) - shadow.fillRoundedRect(-CARD_W / 2 + 5, -CARD_H / 2 + 5, CARD_W, CARD_H, RADIUS) + shadow.fillStyle(0x000000, 0.4) + shadow.fillRoundedRect(-CARD_W / 2 + 3, -CARD_H / 2 + 3, CARD_W, CARD_H, RADIUS) this.add(shadow) - // ── 2. Fond couleur équipe ──────────────────────────────────────────────── - const bg = this.scene.add.graphics() - bg.fillStyle(this._darken(teamHex, 0.25)) - bg.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS) - this.add(bg) - - // ── 3. Image portrait (texture déjà clippée dans BootScene via canvas) ─── + // ── 2. Image portrait plein cadre (texture déjà clippée dans BootScene) ── if (hasTexture) { - // La texture est déjà aux bonnes dimensions et clippée en arrondi - // → simple Image, pas de masque, suit le container nativement const img = this.scene.add.image(0, 0, textureKey) this.add(img) } else { - // Fallback : initiale + // Fallback : fond sombre + initiale + const bg = this.scene.add.graphics() + bg.fillStyle(0x2a1810, 1) + bg.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS) + this.add(bg) const initial = this.scene.add.text(0, -10, this.cardData.name[0], { fontSize: '70px', fontFamily: 'Georgia', color: '#ffffff' - }).setOrigin(0.5).setAlpha(0.1) + }).setOrigin(0.5).setAlpha(0.15) this.add(initial) } - // ── 4. Bordure couleur équipe ───────────────────────────────────────────── + // ── 3. Fin liseré couleur équipe (discret, remplace la bordure épaisse) ── const border = this.scene.add.graphics() - border.lineStyle(2.5, teamHex, 1) + border.lineStyle(1.5, teamHex, 0.9) border.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS) this.add(border) - // ── 5. Titre en BAS ─────────────────────────────────────────────────────── + // ── 4. Nom en bas, simple texte avec contour (pas de bandeau) ──────────── this.add(this.scene.add.text( - 0, CARD_H / 2 - TITLE_H / 2, + 0, CARD_H / 2 - 14, this.cardData.name, { - fontSize: '12px', + fontSize: '13px', fontFamily: 'Georgia, serif', fontStyle: 'bold', color: '#f5e6c8', @@ -73,13 +72,13 @@ export class CardObject extends Phaser.GameObjects.Container { } ).setOrigin(0.5, 0.5)) - // ── 6. Badges attributs dans les 4 coins ───────────────────────────────── - this._addCornerBadge('food', -CARD_W / 2, -CARD_H / 2, 'topLeft') - this._addCornerBadge('war', CARD_W / 2, -CARD_H / 2, 'topRight') - this._addCornerBadge('health', -CARD_W / 2, CARD_H / 2, 'bottomLeft') - this._addCornerBadge('diplo', CARD_W / 2, CARD_H / 2, 'bottomRight') + // ── 5. Chiffres discrets dans les 4 coins ──────────────────────────────── + this._addCornerValue('food', -CARD_W / 2 + CORNER_MARGIN, -CARD_H / 2 + CORNER_MARGIN) + this._addCornerValue('war', CARD_W / 2 - CORNER_MARGIN, -CARD_H / 2 + CORNER_MARGIN) + this._addCornerValue('health', -CARD_W / 2 + CORNER_MARGIN, CARD_H / 2 - CORNER_MARGIN - 22) + this._addCornerValue('diplo', CARD_W / 2 - CORNER_MARGIN, CARD_H / 2 - CORNER_MARGIN - 22) - // ── 7. Interactivité ────────────────────────────────────────────────────── + // ── 6. Interactivité ────────────────────────────────────────────────────── this.setSize(CARD_W, CARD_H) this.setInteractive({ cursor: 'pointer' }) this.on('pointerover', this._onHover, this) @@ -87,38 +86,17 @@ export class CardObject extends Phaser.GameObjects.Container { this.on('pointerdown', this._onDown, this) } - _addCornerBadge(attr, bx, by, corner) { - const { color, label } = ATTR[attr] + /** + * Valeur d'attribut discrète dans un coin : simple chiffre coloré avec + * contour sombre, sans fond ni cercle, pour rester lisible sur le portrait + * sans surcharger visuellement la carte. + */ + _addCornerValue(attr, cx, cy) { const value = this.cardData[attr] - const S = BADGE - - let rx = bx, ry = by - if (corner === 'topRight' || corner === 'bottomRight') rx = bx - S - if (corner === 'bottomLeft' || corner === 'bottomRight') ry = by - S - - const cornerRadii = { - topLeft: { tl: RADIUS, tr: 2, bl: 2, br: RADIUS / 2 }, - topRight: { tl: 2, tr: RADIUS, bl: RADIUS / 2, br: 2 }, - bottomLeft: { tl: 2, tr: RADIUS / 2, bl: RADIUS, br: 2 }, - bottomRight: { tl: RADIUS / 2, tr: 2, bl: 2, br: RADIUS } - } - - const g = this.scene.add.graphics() - g.fillStyle(color, 0.95) - g.fillRoundedRect(rx, ry, S, S, cornerRadii[corner]) - this.add(g) - - const cx = rx + S / 2 - const cy = ry + S / 2 - 2 - this.add(this.scene.add.text(cx, cy, String(value), { - fontSize: '14px', fontFamily: 'Georgia, serif', fontStyle: 'bold', - color: '#ffffff', stroke: '#000000', strokeThickness: 2 - }).setOrigin(0.5, 0.5)) - - this.add(this.scene.add.text(cx, cy + 9, label, { - fontSize: '6px', fontFamily: 'Arial, sans-serif', color: '#ffffffdd' - }).setOrigin(0.5, 0.5)) + fontSize: '15px', fontFamily: 'Georgia, serif', fontStyle: 'bold', + color: ATTR_COLORS[attr], stroke: '#000000', strokeThickness: 3.5 + }).setOrigin(0.5)) } // ── Interactions ────────────────────────────────────────────────────────────── diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js new file mode 100644 index 0000000..c17c426 --- /dev/null +++ b/client/src/game/objects/ItemBoardToken.js @@ -0,0 +1,313 @@ +import Phaser from 'phaser' + +const ATTR_COLORS = { + food: 0xf0c030, + war: 0xdd2222, + health: 0x22aa44, + diplo: 0x3399ee +} + +// Taille du jeton compact affiché en permanence sur le plateau (avant mise +// à l'échelle éventuelle via displayScale — voir constructeur). +export const ICON_SIZE = 46 + +// Taille de la carte détaillée affichée au survol. Volontairement grande et +// avec des polices généreuses pour que la description de l'effet reste +// lisible même pour les mini-jetons (mes objets / ceux des adversaires), +// qui utilisent un displayScale réduit pour le jeton compact SANS que cela +// ne réduise la taille réelle de la carte détaillée (voir _showExpanded). +const EXPANDED_W = 190 +const EXPANDED_H = 230 +const EXPANDED_IMAGE_H = 110 +const BUY_BTN_H = 26 + +// La carte agrandie s'allonge pour laisser la place au bouton ACHETER +const EXPANDED_H_WITH_BTN = EXPANDED_H + BUY_BTN_H + 10 + +// Délai avant de refermer la carte agrandie, pour tolérer le passage de la +// souris entre le jeton compact et la carte agrandie sans clignotement. +const HOVER_HIDE_DELAY = 100 + +// Délai symétrique avant d'ouvrir la carte agrandie : sans lui, un simple +// survol rapide de la souris passant sur plusieurs jetons adjacents (espacés +// de seulement quelques dizaines de pixels) déclencherait une reconstruction +// complète (~9 GameObjects, dont un Text avec wordWrap) à chaque jeton +// traversé, ce qui peut créer un à-coup visuel perceptible. +const HOVER_SHOW_DELAY = 80 + +/** + * Jeton d'objet affiché en permanence sur le plateau de jeu (pas de panneau + * modal). À l'état normal, seule une petite icône est visible. Au survol, + * une carte détaillée (image + nom + description de l'effet) apparaît par + * dessus le plateau, avec un bouton "ACHETER" ; elle disparaît dès que la + * souris s'éloigne. + * + * status: 'available' | 'owned' | 'taken' + * affordable: true si le joueur a assez de points dans la jauge liée à cet + * objet pour l'acheter maintenant (n'a de sens que si status='available'). + * Le bouton "ACHETER" n'est cliquable que si affordable est true — sinon + * il est grisé pour indiquer clairement pourquoi l'achat n'est pas possible. + * displayScale: échelle du JETON COMPACT uniquement (ex: 0.55 pour les mini + * cartes des adversaires, 0.7 pour les miennes, 1 pour la boutique). La + * carte détaillée affichée au survol n'hérite JAMAIS de cette échelle — + * elle reste toujours à sa taille réelle pour rester lisible, quel que + * soit le contexte d'où elle a été ouverte. + */ +export class ItemBoardToken extends Phaser.GameObjects.Container { + constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1) { + super(scene, x, y) + this.itemData = itemData + this.status = status + this.affordable = affordable + this._displayScale = displayScale + this._expanded = null + this._hideTimer = null + this._showTimer = null + this._buildCompact() + scene.add.existing(this) + } + + _buildCompact() { + const attrColor = ATTR_COLORS[this.itemData.attr] ?? 0x888888 + const dimmed = this.status === 'taken' + const S = ICON_SIZE + + // Tous les visuels du jeton compact sont regroupés dans un sous-container + // dédié, sur lequel on applique displayScale — la carte détaillée (ajoutée + // plus tard directement sur `this`, l'outer container qui reste TOUJOURS + // à l'échelle 1) n'hérite ainsi jamais de ce rétrécissement. + const compact = this.scene.add.container(0, 0) + this.add(compact) + + const shadow = this.scene.add.graphics() + shadow.fillStyle(0x000000, 0.4) + shadow.fillRoundedRect(-S / 2 + 2, -S / 2 + 2, S, S, 8) + compact.add(shadow) + + const bg = this.scene.add.graphics() + bg.fillStyle(0x1a0f08, 1) + bg.fillRoundedRect(-S / 2, -S / 2, S, S, 8) + compact.add(bg) + + const textureKey = `item-${this.itemData.id}` + if (this.scene.textures.exists(textureKey)) { + const img = this.scene.add.image(0, -3, textureKey) + img.setDisplaySize(S - 10, S - 16) + compact.add(img) + } + + const border = this.scene.add.graphics() + border.lineStyle(2, attrColor, 1) + border.strokeRoundedRect(-S / 2, -S / 2, S, S, 8) + compact.add(border) + + // Badge de coût (coin haut-droit) + const bx = S / 2 - 7, by = -S / 2 + 7 + const costRing = this.scene.add.graphics() + costRing.fillStyle(0x1a0a00, 1) + costRing.fillCircle(bx, by, 9) + compact.add(costRing) + compact.add(this.scene.add.text(bx, by, String(this.itemData.cost), { + fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' + }).setOrigin(0.5)) + + if (this.status === 'owned') { + const check = this.scene.add.graphics() + check.fillStyle(0x2e7d32, 1) + check.fillCircle(-S / 2 + 7, -S / 2 + 7, 8) + compact.add(check) + compact.add(this.scene.add.text(-S / 2 + 7, -S / 2 + 7, '✔', { + fontSize: '9px', fontFamily: 'Arial', fontStyle: 'bold', color: '#ffffff' + }).setOrigin(0.5)) + } + + if (dimmed) { + const overlay = this.scene.add.graphics() + overlay.fillStyle(0x000000, 0.55) + overlay.fillRoundedRect(-S / 2, -S / 2, S, S, 8) + compact.add(overlay) + } + + compact.setScale(this._displayScale) + + // La zone interactive (hit area) doit correspondre à la taille VISUELLE + // réelle du jeton (après mise à l'échelle), sinon le survol/clic ne + // correspondrait plus à ce que l'utilisateur voit à l'écran. + const hitSize = S * this._displayScale + this.setSize(hitSize, hitSize) + this.setInteractive({ cursor: this.status === 'available' ? 'pointer' : 'default' }) + this.on('pointerover', this._onHoverIn, this) + this.on('pointerout', this._onHoverOut, this) + } + + _onHoverIn() { + this._cancelHide() + this._scheduleShow() + } + + _onHoverOut() { + this._cancelShow() + this._scheduleHide() + } + + _cancelHide() { + if (this._hideTimer) { clearTimeout(this._hideTimer); this._hideTimer = null } + } + + _scheduleHide() { + this._cancelHide() + this._hideTimer = setTimeout(() => this._hideExpanded(), HOVER_HIDE_DELAY) + } + + _cancelShow() { + if (this._showTimer) { clearTimeout(this._showTimer); this._showTimer = null } + } + + _scheduleShow() { + this._cancelShow() + if (this._expanded) return // déjà affichée, rien à refaire + this._showTimer = setTimeout(() => { + this._showTimer = null + this._showExpanded() + }, HOVER_SHOW_DELAY) + } + + /** + * Construit et affiche la carte détaillée. Elle est ajoutée directement + * sur `this` (l'outer container, toujours à l'échelle 1) — jamais sur le + * sous-container `compact` mis à l'échelle réduite — afin de toujours + * s'afficher à sa taille réelle et rester lisible, quel que soit le + * displayScale du jeton d'où elle a été ouverte (jeton boutique à taille + * normale, ou mini-jeton "posé sur la table" pour moi/les adversaires). + * + * Elle est ancrée sur le coin HAUT-GAUCHE du jeton compact (dont la taille + * visuelle dépend de displayScale) plutôt que centrée dessus, pour rester + * dans les limites du canvas quel que soit l'emplacement du jeton : elle + * s'étend uniquement vers la droite et vers le bas, jamais vers la gauche + * (où se trouve le bord du canvas pour la colonne boutique) ni vers le + * haut (où pourrait se trouver le bord supérieur du canvas). + */ + _showExpanded() { + if (this._expanded) return + + const isAvailable = this.status === 'available' + const cardH = isAvailable ? EXPANDED_H_WITH_BTN : EXPANDED_H + + // Demi-taille visuelle réelle du jeton compact (après displayScale) : + // sert à aligner le coin haut-gauche de la carte détaillée sur le coin + // haut-gauche du jeton compact tel qu'il apparaît réellement à l'écran. + const iconHalf = (ICON_SIZE * this._displayScale) / 2 + const dx = EXPANDED_W / 2 - iconHalf + const dy = cardH / 2 - iconHalf + + const attrColor = ATTR_COLORS[this.itemData.attr] ?? 0x888888 + const c = this.scene.add.container(dx, dy) + + const shadow = this.scene.add.graphics() + shadow.fillStyle(0x000000, 0.5) + shadow.fillRoundedRect(-EXPANDED_W / 2 + 3, -cardH / 2 + 3, EXPANDED_W, cardH, 12) + c.add(shadow) + + const bg = this.scene.add.graphics() + bg.fillStyle(0x1a0f08, 1) + bg.fillRoundedRect(-EXPANDED_W / 2, -cardH / 2, EXPANDED_W, cardH, 12) + c.add(bg) + + const textureKey = `item-${this.itemData.id}` + const imgY = -cardH / 2 + if (this.scene.textures.exists(textureKey)) { + const img = this.scene.add.image(0, imgY, textureKey).setOrigin(0.5, 0) + img.setDisplaySize(EXPANDED_W, EXPANDED_IMAGE_H) + c.add(img) + } + + const divider = this.scene.add.graphics() + divider.fillStyle(attrColor, 1) + divider.fillRect(-EXPANDED_W / 2, imgY + EXPANDED_IMAGE_H, EXPANDED_W, 3) + c.add(divider) + + c.add(this.scene.add.text(0, imgY + EXPANDED_IMAGE_H + 12, this.itemData.name, { + fontSize: '16px', fontFamily: 'Georgia, serif', fontStyle: 'bold', + color: '#f5e6c8', align: 'center' + }).setOrigin(0.5, 0)) + + c.add(this.scene.add.text(0, imgY + EXPANDED_IMAGE_H + 36, this.itemData.description, { + fontSize: '13px', fontFamily: 'Georgia, serif', color: '#e8d9c0', + align: 'center', lineSpacing: 4, wordWrap: { width: EXPANDED_W - 20 } + }).setOrigin(0.5, 0)) + + const border = this.scene.add.graphics() + border.lineStyle(2.5, attrColor, 1) + border.strokeRoundedRect(-EXPANDED_W / 2, -cardH / 2, EXPANDED_W, cardH, 12) + c.add(border) + + if (!isAvailable) { + const bandColor = this.status === 'owned' ? 0x2e7d32 : 0x555555 + const bandText = this.status === 'owned' ? '✔ POSSÉDÉ' : 'INDISPONIBLE' + const band = this.scene.add.graphics() + band.fillStyle(bandColor, 0.95) + band.fillRoundedRect(-EXPANDED_W / 2 + 4, -cardH / 2 - 20, EXPANDED_W - 8, 20, 6) + c.add(band) + c.add(this.scene.add.text(0, -cardH / 2 - 10, bandText, { + fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' + }).setOrigin(0.5)) + } + + // ── Bouton ACHETER : cliquable uniquement si affordable, sinon grisé ───── + // Distinct du reste de la carte (qui ne fait qu'afficher les infos) pour + // que le joueur voie explicitement s'il PEUT acheter maintenant ou non, + // au lieu de deviner en cliquant n'importe où sur la carte. + if (isAvailable) { + const btnY = cardH / 2 - BUY_BTN_H / 2 - 8 + const btnW = EXPANDED_W - 20 + const canBuy = this.affordable + + const btnBg = this.scene.add.graphics() + btnBg.fillStyle(canBuy ? 0x2e7d32 : 0x3a3a3a, 1) + btnBg.fillRoundedRect(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H, 6) + btnBg.lineStyle(2, canBuy ? 0x7CFC9A : 0x666666, 1) + btnBg.strokeRoundedRect(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H, 6) + c.add(btnBg) + + const btnText = this.scene.add.text(0, btnY, canBuy ? 'ACHETER' : 'INSUFFISANT', { + fontSize: '12px', fontFamily: 'Georgia', fontStyle: 'bold', + color: canBuy ? '#ffffff' : '#999999' + }).setOrigin(0.5) + c.add(btnText) + + if (canBuy) { + btnBg.setInteractive( + new Phaser.Geom.Rectangle(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H), + Phaser.Geom.Rectangle.Contains + ) + btnBg.input.cursor = 'pointer' + btnBg.on('pointerdown', () => this.emit('buy', this.itemData)) + btnBg.on('pointerover', () => { this._cancelHide(); btnBg.setScale(1.03) }) + btnBg.on('pointerout', () => { this._scheduleHide(); btnBg.setScale(1) }) + } + } + + c.setSize(EXPANDED_W, cardH) + c.setInteractive({ cursor: 'default' }) + c.on('pointerover', () => this._cancelHide()) + c.on('pointerout', () => this._scheduleHide()) + + // Profondeur élevée pour passer par-dessus le reste du plateau. + this.add(c) + this._expanded = c + this._baseDepth = this.depth + this.setDepth(500) + } + + _hideExpanded() { + if (!this._expanded) return + this._expanded.destroy() + this._expanded = null + this.setDepth(this._baseDepth ?? 0) + } + + destroy(fromScene) { + this._cancelHide() + super.destroy(fromScene) + } +} diff --git a/client/src/game/objects/cardLayout.js b/client/src/game/objects/cardLayout.js new file mode 100644 index 0000000..4ca8632 --- /dev/null +++ b/client/src/game/objects/cardLayout.js @@ -0,0 +1,9 @@ +/** + * Constantes de dimensionnement des cartes, partagées entre BootScene + * (génération des textures portrait clippées) et CardObject (rendu en jeu), + * pour éviter toute divergence entre les deux (ex: texture générée à une + * taille différente de celle utilisée à l'affichage → image floue/étirée). + */ +export const CARD_W = 145 +export const CARD_H = 210 +export const RADIUS = 12 diff --git a/client/src/game/objects/itemLayout.js b/client/src/game/objects/itemLayout.js new file mode 100644 index 0000000..5d9afde --- /dev/null +++ b/client/src/game/objects/itemLayout.js @@ -0,0 +1,10 @@ +/** + * Constantes de dimensionnement des textures portrait des objets, générées + * une fois dans BootScene (découpe canvas de l'image brute en rectangle + * arrondi haut). ItemBoardToken réutilise ensuite cette texture, redimensionnée + * via setDisplaySize() pour l'icône compacte et la carte agrandie au survol. + */ +export const ITEM_CARD_W = 150 +export const ITEM_CARD_H = 215 +export const ITEM_RADIUS = 12 +export const ITEM_IMAGE_H = 118 // hauteur de la zone portrait (reste = zone texte) diff --git a/client/src/game/scenes/BootScene.js b/client/src/game/scenes/BootScene.js index c431d86..6b5f13f 100644 --- a/client/src/game/scenes/BootScene.js +++ b/client/src/game/scenes/BootScene.js @@ -1,4 +1,7 @@ import Phaser from 'phaser' +import { CARD_W, CARD_H, RADIUS } from '../objects/cardLayout' +import { ITEM_CARD_W, ITEM_IMAGE_H, ITEM_RADIUS } from '../objects/itemLayout' +import { ITEMS } from '../items' const CARD_IMAGES = [ { id: 'queen', file: 'reine.png' }, @@ -15,10 +18,6 @@ const CARD_IMAGES = [ { id: 'bishop', file: 'eveque.png' } ] -const CARD_W = 130 -const CARD_H = 190 -const RADIUS = 10 - export class BootScene extends Phaser.Scene { constructor() { super('BootScene') @@ -44,10 +43,15 @@ export class BootScene extends Phaser.Scene { barFill.destroy() }) - // Charger les images brutes + // Charger les images brutes des personnages CARD_IMAGES.forEach(({ id, file }) => { this.load.image(`raw-${id}`, `/cards/${file}`) }) + + // Charger les images brutes des objets + ITEMS.forEach(({ id, image }) => { + this.load.image(`raw-item-${id}`, `/items/${image}`) + }) } create() { @@ -56,6 +60,12 @@ export class BootScene extends Phaser.Scene { this._generateClippedTexture(id) }) + // Pour chaque objet, générer une texture portrait (zone haute uniquement, + // le bas de la carte objet est réservé au texte d'effet côté ItemObject) + ITEMS.forEach(({ id }) => { + this._generateItemTexture(id) + }) + this.scene.start('GameScene') } @@ -121,4 +131,62 @@ export class BootScene extends Phaser.Scene { // Enregistrer comme texture Phaser this.textures.addCanvas(outKey, canvas) } + + /** + * Crée une texture `item-{id}` de dimensions ITEM_CARD_W × ITEM_IMAGE_H + * (uniquement la zone portrait en haut de la carte objet, coins arrondis + * en haut seulement — le bas de la carte est une zone de texte gérée par + * ItemObject, pas par cette texture). + */ + _generateItemTexture(id) { + const rawKey = `raw-item-${id}` + const outKey = `item-${id}` + + if (!this.textures.exists(rawKey)) return + if (this.textures.exists(outKey)) return + + const srcImage = this.textures.get(rawKey).getSourceImage() + + const w = ITEM_CARD_W + const h = ITEM_IMAGE_H + const r = ITEM_RADIUS + + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d') + + // Chemin arrondi seulement en haut (bas droit, ce sera raccordé à la + // zone de texte qui suit immédiatement en dessous dans ItemObject) + ctx.beginPath() + ctx.moveTo(r, 0) + ctx.lineTo(w - r, 0) + ctx.quadraticCurveTo(w, 0, w, r) + ctx.lineTo(w, h) + ctx.lineTo(0, h) + ctx.lineTo(0, r) + ctx.quadraticCurveTo(0, 0, r, 0) + ctx.closePath() + ctx.clip() + + // Dessiner l'image en mode "cover" (ratio conservé, centré) + const scaleX = w / srcImage.width + const scaleY = h / srcImage.height + const scale = Math.max(scaleX, scaleY) + const dw = srcImage.width * scale + const dh = srcImage.height * scale + const ox = (w - dw) / 2 + const oy = (h - dh) / 2 + + ctx.drawImage(srcImage, ox, oy, dw, dh) + + // Léger dégradé bas pour raccorder proprement à la zone de texte + const gBot = ctx.createLinearGradient(0, h - 24, 0, h) + gBot.addColorStop(0, 'rgba(0,0,0,0)') + gBot.addColorStop(1, 'rgba(0,0,0,0.55)') + ctx.fillStyle = gBot + ctx.fillRect(0, h - 24, w, 24) + + this.textures.addCanvas(outKey, canvas) + } } diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index cc4a3f0..7f61b43 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -1,9 +1,18 @@ import Phaser from 'phaser' import { CardObject } from '../objects/CardObject' +import { CARD_W, CARD_H } from '../objects/cardLayout' +import { ItemBoardToken, ICON_SIZE } from '../objects/ItemBoardToken' +import { ITEMS, getItemById } from '../items' // Marge depuis le bas du canvas pour éviter la barre Windows const BOTTOM_MARGIN = 60 +// Espacement max entre cartes en main et hauteur de la zone de main, +// dérivés de la taille réelle des cartes (cardLayout) pour rester cohérents +// si la taille des cartes change. +const HAND_SPACING_MAX = CARD_W + 35 +const HAND_Y_OFFSET = CARD_H / 2 + 15 + // Couleurs équipes (synchronisé avec le serveur) const TEAM_COLORS = { 0: 0xcc2200, @@ -12,7 +21,7 @@ const TEAM_COLORS = { 3: 0xb8860b } -// Couleurs attributs +// Couleurs attributs (hex, pour les Graphics) const ATTR_COLORS = { food: 0xf0c030, war: 0xdd2222, @@ -20,6 +29,14 @@ const ATTR_COLORS = { diplo: 0x3399ee } +// Couleurs attributs (CSS, pour les Text — cohérent avec CardObject) +const ATTR_COLORS_CSS = { + food: '#f0c030', + war: '#ff5555', + health: '#4ade80', + diplo: '#60a5fa' +} + export class GameScene extends Phaser.Scene { constructor() { @@ -28,6 +45,15 @@ export class GameScene extends Phaser.Scene { this.selectedCard = null this.resolveObjects = [] this._canPlay = false // true uniquement quand c'est la phase 'selecting' locale + this._lastHandTurn = null // dernier tour pour lequel la main a été (re)construite + this._lastRoundResult = null // { playedCards, gains } affichés actuellement (phase resolve) + this._resizeTimer = null // debounce du redimensionnement du canvas + + // ── Objets sur le plateau (jetons permanents, pas de panneau modal) ──────── + this.itemTokens = [] // ItemBoardToken (boutique) actuellement affichés + this._shopState = null // { myItems, pendingBuyItemId, availableItems, firstPlayerName } + this.myItemTokens = [] // ItemBoardToken des objets que JE possède, à côté de ma main + this.opponentItemTokens = [] // ItemBoardToken des objets possédés par les autres joueurs } create() { @@ -37,21 +63,62 @@ export class GameScene extends Phaser.Scene { this._buildTable() this._buildPlayButton() + this._buildItemTokens() this._setupSocketListeners() + // Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue + // grandit après réception des vraies données de partie (jauges des joueurs). + // Sans repositionnement, les éléments d'UI (dont le bouton de confirmation) + // restent calés sur l'ancienne hauteur et finissent hors de la zone cliquable. + this.scale.on('resize', this._onResizeDebounced, this) + + // IMPORTANT : Phaser n'appelle jamais une méthode `shutdown()` définie sur la + // Scene elle-même — il faut s'abonner explicitement à l'événement du cycle de + // vie pour libérer les listeners, sinon ils s'accumulent à chaque (re)création + // de la scène (navigation vers une nouvelle partie, restart, etc.). + this.events.once(Phaser.Scenes.Events.SHUTDOWN, this._onShutdown, this) + this.events.once(Phaser.Scenes.Events.DESTROY, this._onShutdown, this) + this.socket.emit('request-state', { roomId: this.roomId }) } + _onShutdown() { + this.scale.off('resize', this._onResizeDebounced, this) + if (this._resizeTimer) { + clearTimeout(this._resizeTimer) + this._resizeTimer = null + } + if (this._errorTimer) { + clearTimeout(this._errorTimer) + this._errorTimer = null + } + } + + /** + * Regroupe (debounce) les événements resize rapprochés : Scale.RESIZE peut + * émettre plusieurs événements en rafale (redimensionnement de fenêtre en + * direct, réflows successifs du HUD) ; sans regroupement, chaque événement + * déclencherait une reconstruction destructive complète (slots, légende) + * et de nouveaux tweens sur la main, ce qui dégraderait les performances. + */ + _onResizeDebounced(gameSize) { + if (this._resizeTimer) clearTimeout(this._resizeTimer) + this._resizeTimer = setTimeout(() => { + this._resizeTimer = null + this._onResize(gameSize) + }, 120) + } + // ─── Construction du plateau ───────────────────────────────────────────────── _buildTable() { const { width, height } = this.scale // Fond parchemin - this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500) + this.bgRect = this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500) // Zone de jeu centrale - this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7) + this.zoneRect = this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7) .setStrokeStyle(1, 0xd4a73230) // Titre du tour @@ -73,45 +140,121 @@ export class GameScene extends Phaser.Scene { this.slotObjects = [] // pour pouvoir les détruire/recréer // Légende attributs (coin en bas à droite, au-dessus de la marge Windows) + this.legendObjects = [] this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10) } + /** + * Repositionne tous les éléments dépendants de la taille du canvas. + * Nécessaire car le canvas Phaser (mode Scale.RESIZE) se redimensionne + * une fois que le HUD Vue grandit (jauges des joueurs affichées), ce qui + * décale la zone visible sans que les éléments d'UI ne soient recalés — + * rendant notamment le bouton de confirmation hors-zone cliquable. + */ + _onResize(gameSize) { + const width = gameSize.width + const height = gameSize.height + + // Fond + zone centrale + if (this.bgRect) { + this.bgRect.setPosition(width / 2, height / 2) + this.bgRect.setSize(width, height) + } + if (this.zoneRect) { + this.zoneRect.setPosition(width / 2, height / 2 - 60) + this.zoneRect.setSize(width * 0.7, height * 0.38) + } + + // Textes du haut + if (this.turnText) this.turnText.setPosition(width / 2, 18) + if (this.phaseText) this.phaseText.setPosition(width / 2, 40) + + // Légende attributs + this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10) + + // Slots de jeu. Reconstruire les slots est destructif et invaliderait les + // coordonnées déjà utilisées par les cartes révélées à l'écran (phase + // 'resolve'/'end') si on ne les redessine pas aussi — donc on les + // reconstruit ensemble pour rester synchronisés. + if (this.playSlots.length) { + this._buildSlots(this.playSlots.length) + if (this._lastRoundResult) { + this._showPlayedCards(this._lastRoundResult.playedCards, this._lastRoundResult.gains) + } + } + + // Bouton de confirmation + compteur + this._layoutPlayButton() + + // Bande de jetons d'objet en haut du plateau + this._layoutItemTokens() + + // Main du joueur (repositionnement simple, sans reconstruction) + this._repositionHand() + } + _buildSlots(playerCount) { const { width, height } = this.scale - // Détruire les anciens slots + // Détruire les anciens slots (et les mini-cartes objets des adversaires) this.slotObjects.forEach(o => o.destroy()) this.slotObjects = [] this.playSlots = [] + this.opponentItemTokens.forEach(t => t.destroy()) + this.opponentItemTokens = [] const slotY = height / 2 - 60 - const spacing = 140 + const spacing = CARD_W + 30 const totalW = playerCount * spacing const startX = width / 2 - totalW / 2 + spacing / 2 for (let i = 0; i < playerCount; i++) { const x = startX + i * spacing + const player = this.store.players[i] const g = this.add.graphics() g.lineStyle(1, 0xd4a73240) - g.strokeRoundedRect(-65, -95, 130, 190, 8) + g.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 8) g.x = x g.y = slotY this.slotObjects.push(g) const label = this.add.text(x, slotY + 108, - this.store.players[i]?.name ?? `J${i + 1}`, { + player?.name ?? `J${i + 1}`, { fontSize: '11px', fontFamily: 'Georgia', color: '#4a3020' }).setOrigin(0.5) this.slotObjects.push(label) + // Mini-cartes objets "posées devant" cet adversaire, juste sous son + // slot de jeu — comme sur ma propre table à côté de ma main. On ne le + // fait pas pour MON propre slot : mes objets sont déjà affichés à côté + // de ma main (plus pertinent puisque je vois ma main, pas mon slot). + const isMe = player?.teamIndex === this.store.myTeamIndex + if (!isMe) { + const ownedItems = player?.items ?? [] + const tokenGap = ICON_SIZE * 0.55 + 6 + const startTokenX = x - (ownedItems.length - 1) * tokenGap / 2 + ownedItems.forEach((itemId, k) => { + const item = getItemById(itemId) + if (!item) return + // displayScale=0.55 réduit uniquement le jeton compact ; la carte + // détaillée au survol reste à sa taille normale (voir ItemBoardToken). + const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY + 128, item, 'owned', false, 0.55) + this.opponentItemTokens.push(token) + }) + } + this.playSlots.push({ x, y: slotY }) } } _buildAttrLegend(x, y) { + // Détruire l'ancienne légende avant reconstruction (appelé aussi au resize) + if (this.legendObjects) this.legendObjects.forEach(o => o.destroy()) + this.legendObjects = [] + const entries = [ { attr: 'food', label: '🟡 Nourriture — haut gauche' }, { attr: 'war', label: '🔴 Guerre — haut droite' }, @@ -120,17 +263,24 @@ export class GameScene extends Phaser.Scene { ] entries.forEach((e, i) => { - this.add.text(x, y - i * 16, e.label, { - fontSize: '10px', - fontFamily: 'Georgia', - color: '#6a5040' - }).setOrigin(1, 1) + this.legendObjects.push( + this.add.text(x, y - i * 16, e.label, { + fontSize: '10px', + fontFamily: 'Georgia', + color: '#6a5040' + }).setOrigin(1, 1) + ) }) } + /** Position Y (unique source de vérité) du bouton de confirmation et de son fond. */ + _playButtonY() { + return this.scale.height - BOTTOM_MARGIN - 20 + } + _buildPlayButton() { - const { width, height } = this.scale - const btnY = height - BOTTOM_MARGIN - 20 + const { width } = this.scale + const btnY = this._playButtonY() // Fond du bouton (rectangle arrondi via Graphics) this.playBtnBg = this.add.graphics() @@ -160,9 +310,21 @@ export class GameScene extends Phaser.Scene { }).setOrigin(0.5).setDepth(100) } + /** Recale le bouton de confirmation et son compteur sur la taille actuelle du canvas. */ + _layoutPlayButton() { + const { width } = this.scale + const btnY = this._playButtonY() + + if (this.playBtn) this.playBtn.setPosition(width / 2, btnY) + if (this.readyText) this.readyText.setPosition(width / 2, btnY + 28) + + // Redessiner le fond du bouton (dépend de width/height) en conservant son état actif/inactif + this._drawBtnBg(!!this._pulseTween) + } + _drawBtnBg(active) { - const { width, height } = this.scale - const btnY = height - BOTTOM_MARGIN - 20 + const { width } = this.scale + const btnY = this._playButtonY() const btnW = 280 const btnH = 42 this.playBtnBg.clear() @@ -201,27 +363,174 @@ export class GameScene extends Phaser.Scene { if (this._pulseTween) { this._pulseTween.stop(); this._pulseTween = null } } + // ─── Objets sur le plateau (jetons permanents, agrandissement au survol) ──── + + /** + * Colonne verticale sur le bord GAUCHE du plateau (au lieu d'une bande + * horizontale en haut). La bande horizontale précédente plaçait les jetons + * trop près du bord supérieur du canvas : la carte agrandie au survol + * (hauteur ~172px, centrée sur le jeton) débordait au-dessus de y=0 et se + * retrouvait coupée/invisible sous la barre HUD, rendant l'achat impossible + * en pratique. La colonne à gauche laisse assez de marge verticale ET + * horizontale (largeur de carte ~132px) pour que la carte agrandie + * s'affiche toujours entièrement à l'intérieur du canvas. + */ + _itemTokensX() { + return 70 + } + + _itemTokensStartY() { + return 130 + } + + _itemTokensSpacing() { + return ICON_SIZE + 60 // marge verticale généreuse pour la carte agrandie (H ~172px) + } + + /** + * (Re)construit la colonne à gauche avec UNIQUEMENT les objets encore + * disponibles à l'achat. Une fois un objet acheté (par n'importe quel + * joueur), il disparaît complètement de cette colonne — inutile de + * l'afficher grisé/"INDISPONIBLE" puisqu'il ne peut plus jamais être + * acheté par personne (stock unique pour toute la partie). Les objets + * restants remontent pour combler l'espace laissé vide. + */ + _buildItemTokens() { + this.itemTokens.forEach(t => t.destroy()) + this.itemTokens = [] + + const x = this._itemTokensX() + const startY = this._itemTokensStartY() + const spacing = this._itemTokensSpacing() + + const state = this._shopState ?? { myItems: [], availableItems: ITEMS.map(i => i.id) } + + // Ne garder que les objets réellement encore en stock. "owned" (déjà + // possédé par MOI) est logiquement un sous-cas exclu de availableItems + // côté serveur, mais on l'exclut aussi explicitement par sécurité. + const stillAvailable = ITEMS.filter(itemData => + state.availableItems.includes(itemData.id) && !state.myItems.includes(itemData.id) + ) + + // Libellé au-dessus de la colonne : sans lui, rien n'indique que ces + // petites icônes sont cliquables pour acheter (elles n'ont ni bouton ni + // panneau visible en permanence, seul un survol révèle la carte détaillée). + // Masqué si plus aucun objet n'est disponible (colonne vide). + if (stillAvailable.length > 0) { + if (!this.itemTokensLabel) { + this.itemTokensLabel = this.add.text(x, startY - ICON_SIZE / 2 - 16, 'OBJETS\n(survoler)', { + fontSize: '9px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#6a5040', align: 'center' + }).setOrigin(0.5, 1) + } else { + this.itemTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16) + this.itemTokensLabel.setVisible(true) + } + } else if (this.itemTokensLabel) { + this.itemTokensLabel.setVisible(false) + } + + // Jauges du joueur local, pour déterminer si le bouton ACHETER de chaque + // carte doit être cliquable (jauge suffisante) ou grisé. + const me = this.store.players.find(p => p.teamIndex === this.store.myTeamIndex) + const myGauges = me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 } + + stillAvailable.forEach((itemData, i) => { + const affordable = (myGauges[itemData.attr] ?? 0) >= itemData.cost + + const token = new ItemBoardToken(this, x, startY + i * spacing, itemData, 'available', affordable) + token.on('buy', (data) => this._onBuyItem(data)) + this.itemTokens.push(token) + }) + } + + /** Recale la colonne de jetons verticalement sur le bord gauche du canvas. */ + _layoutItemTokens() { + const x = this._itemTokensX() + const startY = this._itemTokensStartY() + const spacing = this._itemTokensSpacing() + if (this.itemTokensLabel) this.itemTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16) + this.itemTokens.forEach((token, i) => token.setPosition(x, startY + i * spacing)) + } + + _onBuyItem(itemData) { + this.socket.emit('buy-item', { roomId: this.roomId, itemId: itemData.id }) + } + + /** + * (Re)construit les mini-cartes des objets que JE possède, affichées juste + * à droite de ma main — comme si je les avais posées devant moi sur la + * table, à portée de main avec mes cartes personnage. + */ + _buildMyItemTokens() { + this.myItemTokens.forEach(t => t.destroy()) + this.myItemTokens = [] + + const myItemIds = this._shopState?.myItems ?? [] + if (myItemIds.length === 0) return + + myItemIds.forEach((itemId, i) => { + const itemData = ITEMS.find(it => it.id === itemId) + if (!itemData) return + // Toujours 'owned' ici : ce sont mes objets déjà achetés, non cliquables. + // displayScale=0.7 réduit uniquement le jeton compact ; la carte + // détaillée au survol reste à sa taille normale (voir ItemBoardToken). + const token = new ItemBoardToken(this, 0, 0, itemData, 'owned', false, 0.7) + this.myItemTokens.push(token) + }) + + this._layoutMyItemTokens() + } + + /** Positionne mes mini-cartes objets juste à droite de ma main de cartes. */ + _layoutMyItemTokens() { + if (this.myItemTokens.length === 0) return + + const { handY, spacing, startX } = this._handGeometry(this.handCards.length) + const handRightEdge = startX + Math.max(this.handCards.length - 1, 0) * spacing + CARD_W / 2 + const gap = 18 + const tokenGap = ICON_SIZE * 0.7 + 10 + + this.myItemTokens.forEach((token, i) => { + token.setPosition(handRightEdge + gap + ICON_SIZE * 0.7 / 2 + i * tokenGap, handY) + }) + } + // ─── Main du joueur ────────────────────────────────────────────────────────── - _buildHand(cards) { + /** + * Calcule la géométrie de la main (position Y, espacement, X de départ) + * pour un nombre de cartes donné. Centralisé ici pour éviter la triplication + * de cette formule (_buildHand / _animateDraw / _repositionHand la + * calculaient chacune séparément), et réutilisé pour positionner mes + * objets achetés juste à côté de la main. + */ + _handGeometry(cardCount) { const { width, height } = this.scale + const n = Math.max(cardCount, 1) + const handY = height - BOTTOM_MARGIN - HAND_Y_OFFSET + const spacing = Math.min(HAND_SPACING_MAX, (width * 0.85) / n) + const totalW = spacing * (n - 1) + const startX = width / 2 - totalW / 2 + return { handY, spacing, startX } + } + _buildHand(cards) { // Vider l'ancienne main this.handCards.forEach(c => c.destroy()) this.handCards = [] if (!cards || cards.length === 0) return - const handY = height - BOTTOM_MARGIN - 110 - const spacing = Math.min(150, (width * 0.85) / cards.length) - const totalW = spacing * (cards.length - 1) - const startX = width / 2 - totalW / 2 + const { handY, spacing, startX } = this._handGeometry(cards.length) cards.forEach((cardData, i) => { const card = new CardObject(this, startX + i * spacing, handY, cardData) card.on('select', this._onCardSelected, this) this.handCards.push(card) }) + + this._layoutMyItemTokens() } _onCardSelected(card) { @@ -261,14 +570,23 @@ export class GameScene extends Phaser.Scene { * @param {Array} gains - gains[i] = { food, war, health, diplo } pour le joueur i */ _showPlayedCards(playedCards, gains = []) { + // Mémoriser le dernier résultat affiché pour pouvoir le redessiner si les + // slots sont reconstruits pendant la phase 'resolve' (ex: resize du canvas). + this._lastRoundResult = { playedCards, gains } + this.resolveObjects.forEach(o => o.destroy()) this.resolveObjects = [] + // Style épuré, cohérent avec CardObject : portrait plein cadre, fin + // liseré couleur équipe, nom simple en bas, valeurs discrètes aux coins. + const rCARD_W = CARD_W + const rCARD_H = CARD_H + const ATTRS_POS = [ - { key: 'food', label: 'NOU', dx: -28, dy: -20 }, - { key: 'war', label: 'GUE', dx: 28, dy: -20 }, - { key: 'health', label: 'SAN', dx: -28, dy: 30 }, - { key: 'diplo', label: 'DIP', dx: 28, dy: 30 } + { key: 'food', dx: -rCARD_W / 2 + 12, dy: -rCARD_H / 2 + 12 }, + { key: 'war', dx: rCARD_W / 2 - 12, dy: -rCARD_H / 2 + 12 }, + { key: 'health', dx: -rCARD_W / 2 + 12, dy: rCARD_H / 2 - 34 }, + { key: 'diplo', dx: rCARD_W / 2 - 12, dy: rCARD_H / 2 - 34 } ] playedCards.forEach((entry, i) => { @@ -277,67 +595,64 @@ export class GameScene extends Phaser.Scene { const teamColor = TEAM_COLORS[entry.teamIndex] ?? 0x888888 const gain = gains[i] ?? {} + const textureKey = `card-${entry.cardId}` + const hasTexture = entry.cardId && this.textures.exists(textureKey) - // ── Fond carte ────────────────────────────────────────── - const g = this.add.graphics() - g.fillStyle(this._darken(teamColor, 0.25)) - g.fillRoundedRect(-55, -90, 110, 180, 6) - g.lineStyle(2, teamColor) - g.strokeRoundedRect(-55, -90, 110, 180, 6) - g.x = slot.x - g.y = slot.y - this.resolveObjects.push(g) + // ── Portrait ou fond de secours ──────────────────────────── + if (hasTexture) { + const img = this.add.image(slot.x, slot.y, textureKey) + this.resolveObjects.push(img) + } else { + const bg = this.add.graphics() + bg.fillStyle(this._darken(teamColor, 0.3)) + bg.fillRoundedRect(-rCARD_W / 2, -rCARD_H / 2, rCARD_W, rCARD_H, 10) + bg.x = slot.x + bg.y = slot.y + this.resolveObjects.push(bg) + } - // ── Nom du joueur ──────────────────────────────────────── + // ── Fin liseré couleur équipe ─────────────────────────────── + const border = this.add.graphics() + border.lineStyle(1.5, teamColor, 0.9) + border.strokeRoundedRect(-rCARD_W / 2, -rCARD_H / 2, rCARD_W, rCARD_H, 10) + border.x = slot.x + border.y = slot.y + this.resolveObjects.push(border) + + // ── Nom du joueur (au-dessus de la carte) ────────────────── this.resolveObjects.push( - this.add.text(slot.x, slot.y - 78, entry.playerName, { - fontSize: '10px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold' + this.add.text(slot.x, slot.y - rCARD_H / 2 - 14, entry.playerName, { + fontSize: '11px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold' }).setOrigin(0.5) ) - // ── Nom de la carte ────────────────────────────────────── + // ── Nom de la carte, simple texte en bas ──────────────────── this.resolveObjects.push( - this.add.text(slot.x, slot.y - 62, entry.cardName, { - fontSize: '12px', fontFamily: 'Georgia', color: '#d4a732', fontStyle: 'bold' + this.add.text(slot.x, slot.y + rCARD_H / 2 - 14, entry.cardName, { + fontSize: '13px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#f5e6c8', stroke: '#000000', strokeThickness: 3 }).setOrigin(0.5) ) - // ── 4 badges attribut + gain ───────────────────────────── - ATTRS_POS.forEach(({ key, label, dx, dy }) => { + // ── Valeurs discrètes aux 4 coins + gain au-dessus si positif ──────── + ATTRS_POS.forEach(({ key, dx, dy }) => { const cx = slot.x + dx const cy = slot.y + dy const cardVal = entry[key] ?? 0 const gainVal = gain[key] ?? 0 - // Badge fond coloré - const badge = this.add.graphics() - badge.fillStyle(ATTR_COLORS[key], gainVal > 0 ? 1.0 : 0.45) - badge.fillRoundedRect(-22, -20, 44, 40, 4) - badge.x = cx - badge.y = cy - this.resolveObjects.push(badge) - - // Valeur de la carte this.resolveObjects.push( - this.add.text(cx, cy - 7, String(cardVal), { - fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold', - color: '#ffffff', stroke: '#000000', strokeThickness: 2 + this.add.text(cx, cy, String(cardVal), { + fontSize: '15px', fontFamily: 'Georgia', fontStyle: 'bold', + color: ATTR_COLORS_CSS[key], stroke: '#000000', strokeThickness: 3.5 }).setOrigin(0.5) ) - // Label abrégé - this.resolveObjects.push( - this.add.text(cx, cy + 5, label, { - fontSize: '7px', fontFamily: 'Arial', color: '#ffffffcc' - }).setOrigin(0.5) - ) - - // Gain (affiché en vert si > 0) if (gainVal > 0) { this.resolveObjects.push( - this.add.text(cx, cy + 16, `+${gainVal}`, { - fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', - color: '#ffffff', stroke: '#000000', strokeThickness: 2 + this.add.text(cx, cy - 16, `+${gainVal}`, { + fontSize: '11px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#7CFC9A', stroke: '#000000', strokeThickness: 3 }).setOrigin(0.5) ) } @@ -352,22 +667,69 @@ export class GameScene extends Phaser.Scene { this.store.updateState(state) this.turnText.setText(`Tour ${state.currentTurn} / ${state.maxTurns}`) + // Mémoriser l'état des objets (possédés/disponibles, priorité du tour) + // et ne rafraîchir les jetons du plateau que si leur statut, OU les + // jauges du joueur local (qui déterminent si le bouton ACHETER est + // cliquable), ont réellement changé. Sans cette garde, la reconstruction + // se déclencherait à chaque 'game-state' reçu (rediffusé à tous à chaque + // carte jouée par n'importe qui), ce qui interromprait l'agrandissement + // au survol en plein milieu. + const newShopState = { + myItems: state.myItems ?? [], + pendingBuyItemId: state.pendingBuyItemId ?? null, + availableItems: state.availableItems ?? [], + firstPlayerName: state.firstPlayerName ?? null + } + const me = (state.players ?? []).find(p => p.teamIndex === state.myTeamIndex) + const myGaugesKey = JSON.stringify(me?.gauges ?? {}) + const shopChanged = !this._shopState || + JSON.stringify(this._shopState.myItems) !== JSON.stringify(newShopState.myItems) || + JSON.stringify(this._shopState.availableItems) !== JSON.stringify(newShopState.availableItems) || + this._lastMyGaugesKey !== myGaugesKey + this._shopState = newShopState + this._lastMyGaugesKey = myGaugesKey + if (shopChanged) this._buildItemTokens() + + // Reconstruire mes mini-cartes objets uniquement si MA liste d'objets + // possédés a changé (indépendant de shopChanged qui réagit aussi aux + // objets encore disponibles / jauges, non pertinents ici). + const myItemsKey = JSON.stringify(newShopState.myItems) + if (this._lastMyItemsKey !== myItemsKey) { + this._lastMyItemsKey = myItemsKey + this._buildMyItemTokens() + } + if (state.phase === 'selecting') { this._canPlay = true this.resolveObjects.forEach(o => o.destroy()) this.resolveObjects = [] + this._lastRoundResult = null this.phaseText.setText('Choisissez une carte puis confirmez') this.readyText.setText('') this._deactivateBtn() - // (Re)construire les slots si le nombre de joueurs a changé ou premier tour - if (this.playSlots.length !== state.players.length) { + // (Re)construire les slots si le nombre de joueurs a changé (premier + // tour), OU si la liste d'objets possédés par un adversaire a changé + // (leurs mini-cartes objets, affichées sous leur slot, doivent alors + // être mises à jour). + const opponentItemsKey = JSON.stringify( + state.players.map(p => p.items ?? []) + ) + if (this.playSlots.length !== state.players.length || this._lastOpponentItemsKey !== opponentItemsKey) { + this._lastOpponentItemsKey = opponentItemsKey this._buildSlots(state.players.length) } - // Si ce n'est pas le premier tour, animer la pioche avant d'afficher la main - if (state.currentTurn > 1) { - this._animateDraw(state.hand) - } else { - this._buildHand(state.hand) + // Ne (re)construire/animer la main que lorsque le tour a réellement changé. + // Le serveur rediffuse 'game-state' à tous les joueurs à chaque carte jouée + // par n'importe qui ; sans cette garde, la main serait reconstruite/animée + // plusieurs fois pour le même tour, provoquant un doublon visuel de carte. + if (this._lastHandTurn !== state.currentTurn) { + this._lastHandTurn = state.currentTurn + // Si ce n'est pas le premier tour, animer la pioche avant d'afficher la main + if (state.currentTurn > 1) { + this._animateDraw(state.hand) + } else { + this._buildHand(state.hand) + } } } @@ -389,11 +751,43 @@ export class GameScene extends Phaser.Scene { } }) - this.socket.on('round-result', ({ playedCards, gains }) => { + this.socket.on('round-result', ({ playedCards, gains, boughtItems }) => { this.phaseText.setText('Résolution du tour...') this.readyText.setText('') this._showPlayedCards(playedCards, gains) + + if (boughtItems && boughtItems.length > 0) { + const names = boughtItems.map(b => `${b.playerName} → ${b.itemName}`).join(' | ') + this.readyText.setText(`🛒 ${names}`) + } }) + + // Retour visuel des refus d'achat (ex: jauge insuffisante, objet déjà + // pris) : sans cet écouteur, le serveur répond bien via socket.emit('error', + // ...) mais rien côté client n'affichait jamais ce message — un clic sur + // une carte objet semblait alors ne rien faire, laissant croire qu'il + // n'y avait aucun moyen d'acheter. + this.socket.on('error', ({ message }) => { + if (!message) return + this._showErrorToast(message) + }) + } + + /** Affiche un message d'erreur temporaire (ex: achat refusé) près du bouton de confirmation. */ + _showErrorToast(message) { + if (this._errorText) { this._errorText.destroy(); this._errorText = null } + if (this._errorTimer) { clearTimeout(this._errorTimer); this._errorTimer = null } + + const { width } = this.scale + this._errorText = this.add.text(width / 2, this._playButtonY() - 46, `⚠ ${message}`, { + fontSize: '13px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#ff6b6b', stroke: '#000000', strokeThickness: 3 + }).setOrigin(0.5).setDepth(150) + + this._errorTimer = setTimeout(() => { + if (this._errorText) { this._errorText.destroy(); this._errorText = null } + this._errorTimer = null + }, 3000) } // ─── Animation pioche ──────────────────────────────────────────────────────── @@ -403,8 +797,6 @@ export class GameScene extends Phaser.Scene { * (toujours la dernière du tableau, ajoutée par nextTurn côté serveur) */ _animateDraw(cards) { - const { width, height } = this.scale - // Afficher immédiatement les cartes déjà en main (toutes sauf la dernière) const existing = cards.slice(0, -1) const newCard = cards[cards.length - 1] @@ -414,11 +806,8 @@ export class GameScene extends Phaser.Scene { if (!newCard) return // Calculer la position finale de la nouvelle carte dans la main complète - const handY = height - BOTTOM_MARGIN - 110 - const spacing = Math.min(150, (width * 0.85) / cards.length) - const totalW = spacing * (cards.length - 1) - const startX = width / 2 - totalW / 2 - const finalX = startX + (cards.length - 1) * spacing + const { handY, spacing, startX } = this._handGeometry(cards.length) + const finalX = startX + (cards.length - 1) * spacing // Créer la carte hors écran (au-dessus) const card = new CardObject(this, finalX, -200, newCard) @@ -446,12 +835,10 @@ export class GameScene extends Phaser.Scene { /** Repositionne toutes les cartes en main avec le bon espacement */ _repositionHand() { - const { width, height } = this.scale - const handY = height - BOTTOM_MARGIN - 110 - const n = this.handCards.length - const spacing = Math.min(150, (width * 0.85) / n) - const totalW = spacing * (n - 1) - const startX = width / 2 - totalW / 2 + const n = this.handCards.length + if (n === 0) return + + const { handY, spacing, startX } = this._handGeometry(n) this.handCards.forEach((card, i) => { const targetX = startX + i * spacing @@ -464,6 +851,8 @@ export class GameScene extends Phaser.Scene { ease: 'Power2' }) }) + + this._layoutMyItemTokens() } // ─── Utilitaires ───────────────────────────────────────────────────────────── diff --git a/client/src/services/socket.js b/client/src/services/socket.js index edc3e18..ea8fef6 100644 --- a/client/src/services/socket.js +++ b/client/src/services/socket.js @@ -22,7 +22,7 @@ export function leaveRoom(roomId) { // Retirer tous les listeners liés au jeu const gameEvents = [ 'room-update', 'game-start', 'game-state', - 'players-ready', 'round-result', 'game-over' + 'players-ready', 'round-result', 'game-over', 'error' ] gameEvents.forEach(ev => socket.off(ev)) } diff --git a/client/src/stores/game.js b/client/src/stores/game.js index 57a14b3..4ce2f55 100644 --- a/client/src/stores/game.js +++ b/client/src/stores/game.js @@ -11,6 +11,12 @@ export const useGameStore = defineStore('game', () => { const myTeamIndex = ref(0) const selectedCard = ref(null) + // ── Objets (achat, effets permanents) ────────────────────────────────────── + const myItems = ref([]) // ids des objets possédés par ce joueur + const pendingBuyItemId = ref(null) // id de l'objet en attente d'achat ce tour (ou null) + const availableItems = ref([]) // ids des objets encore en stock (non achetés) + const firstPlayerName = ref(null) // nom du joueur prioritaire pour l'achat ce tour + function setPlayer(name) { playerName.value = name } @@ -22,6 +28,10 @@ export const useGameStore = defineStore('game', () => { phase.value = state.phase ?? 'idle' myTeamIndex.value = state.myTeamIndex ?? 0 if (state.hand !== undefined) hand.value = state.hand + if (state.myItems !== undefined) myItems.value = state.myItems + if (state.pendingBuyItemId !== undefined) pendingBuyItemId.value = state.pendingBuyItemId + if (state.availableItems !== undefined) availableItems.value = state.availableItems + if (state.firstPlayerName !== undefined) firstPlayerName.value = state.firstPlayerName } function selectCard(card) { selectedCard.value = card } @@ -36,11 +46,16 @@ export const useGameStore = defineStore('game', () => { hand.value = [] myTeamIndex.value = 0 selectedCard.value = null + myItems.value = [] + pendingBuyItemId.value = null + availableItems.value = [] + firstPlayerName.value = null } return { playerName, players, currentTurn, maxTurns, phase, hand, myTeamIndex, selectedCard, + myItems, pendingBuyItemId, availableItems, firstPlayerName, setPlayer, updateState, selectCard, clearSelection, reset } }) diff --git a/client/src/views/GameView.vue b/client/src/views/GameView.vue index 215ed5c..ad37840 100644 --- a/client/src/views/GameView.vue +++ b/client/src/views/GameView.vue @@ -10,6 +10,11 @@ :style="{ borderColor: teamCss[player.teamIndex] }" >