feat: système d'objets achetables (boutique + effets permanents) et refonte visuelle
- Nouveaux personnages illustrés (12 PNG remplacés dans client/public/cards) - 4 objets achetables (Trébuchet, Charrue, Trousse du Médecin, Sceau Royal) avec effets permanents (double_gain, guaranteed_gain, copy_best_in_game), stock unique par partie, priorité d'achat tournante entre joueurs - Boutique en colonne à gauche du plateau : icônes compactes, carte détaillée agrandie au survol (taille découplée du displayScale du jeton), bouton ACHETER actif/grisé selon la jauge du joueur, retrait immédiat de la colonne une fois l'objet acheté (les autres remontent pour combler) - Objets achetés affichés "posés sur la table" : à côté de la main pour soi, sous le slot de jeu pour les adversaires - Icône du premier joueur (priorité d'achat) dans le HUD Vue - Refonte des cartes personnage (CardObject) : portrait plein cadre épuré, valeurs d'attribut discrètes aux 4 coins - Fix removePlayer : nettoyage de pendingBuys/pendingPlays et recalage de firstPlayerIndex à la déconnexion d'un joueur - 77 tests unitaires serveur (node:test) : cards, items, Room, RoomManager - README mis à jour avec les règles complètes du jeu - .gitignore : exclusion de .idea/ et .serena/ (artefacts d'outils locaux)
No files matched your search
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.idea/
|
||||
.serena/
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
@@ -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
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,11 @@
|
||||
:style="{ borderColor: teamCss[player.teamIndex] }"
|
||||
>
|
||||
<div class="player-name" :style="{ color: teamCss[player.teamIndex] }">
|
||||
<span
|
||||
v-if="player.name === gameStore.firstPlayerName"
|
||||
class="first-player-icon"
|
||||
title="Premier joueur : priorité en cas de conflit d'achat d'objet"
|
||||
>👑</span>
|
||||
{{ player.name }}
|
||||
</div>
|
||||
<div class="gauges">
|
||||
@@ -136,6 +141,11 @@ onUnmounted(() => {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.first-player-icon {
|
||||
font-size: 11px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.gauges {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "node --test",
|
||||
"test:watch": "node --test --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { dealHands } = require('./cards')
|
||||
const { ITEMS, getItemById } = require('./items')
|
||||
|
||||
const MAX_PLAYERS = 4
|
||||
const MAX_TURNS = 12
|
||||
@@ -12,16 +13,28 @@ const ATTRS = ['food', 'war', 'health', 'diplo']
|
||||
* gain = max(0, ma_valeur_attr - valeur_attr_voisin_gauche)
|
||||
* Ce gain s'ajoute à la jauge correspondante du joueur.
|
||||
* Le score total est la somme des 4 jauges.
|
||||
*
|
||||
* Objets (voir items.js) :
|
||||
* Chaque objet est acheté avec des points de la jauge à laquelle il est lié
|
||||
* (payé immédiatement à l'achat) et procure un effet PERMANENT appliqué à
|
||||
* chaque résolution de tour tant que le joueur le possède. Chaque objet
|
||||
* n'existe qu'en un seul exemplaire pour toute la partie (stock unique).
|
||||
* Si plusieurs joueurs tentent d'acheter le MÊME objet le même tour, seul le
|
||||
* joueur le plus proche du "premier joueur" (ordre qui tourne chaque tour)
|
||||
* l'obtient ; les autres conservent leurs points.
|
||||
*/
|
||||
|
||||
class Room {
|
||||
constructor(id) {
|
||||
this.id = id
|
||||
this.players = [] // { socketId, name, teamIndex, gauges:{food,war,health,diplo}, hand }
|
||||
this.players = [] // { socketId, name, teamIndex, gauges:{food,war,health,diplo}, hand, items:[] }
|
||||
this.phase = 'lobby' // lobby | selecting | waiting | resolve | end
|
||||
this.currentTurn = 0
|
||||
this.maxTurns = MAX_TURNS
|
||||
this.pendingPlays = new Map() // socketId -> { player, card }
|
||||
this.pendingBuys = new Map() // socketId -> itemId (achats en attente de résolution ce tour)
|
||||
this.availableItems = [] // ids des objets pas encore achetés
|
||||
this.firstPlayerIndex = 0 // index (dans this.players) du joueur prioritaire ce tour
|
||||
}
|
||||
|
||||
get playerCount() { return this.players.length }
|
||||
@@ -38,13 +51,39 @@ class Room {
|
||||
teamIndex: this.players.length,
|
||||
gauges: { food: 0, war: 0, health: 0, diplo: 0 },
|
||||
hand: [],
|
||||
deck: [] // cartes restantes à piocher
|
||||
deck: [], // cartes restantes à piocher
|
||||
items: [] // ids des objets possédés (effets permanents)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
removePlayer(socketId) {
|
||||
// Nettoyer les tentatives d'achat/de carte en attente du joueur qui part :
|
||||
// sinon _resolvePendingBuys() traiterait un socketId fantôme (findIndex
|
||||
// renverrait -1, ce qui le classerait à tort en tête de priorité et
|
||||
// bloquerait l'achat d'un autre joueur réellement éligible).
|
||||
this.pendingBuys.delete(socketId)
|
||||
this.pendingPlays.delete(socketId)
|
||||
|
||||
// Conserver l'identité du "premier joueur" (priorité d'achat) à travers
|
||||
// la suppression : firstPlayerIndex est un index positionnel qui décale
|
||||
// silencieusement de cible si on ne le recale pas après un filter().
|
||||
const firstPlayerSocketId = this.players[this.firstPlayerIndex]?.socketId
|
||||
|
||||
this.players = this.players.filter(p => p.socketId !== socketId)
|
||||
|
||||
if (this.players.length === 0) {
|
||||
this.firstPlayerIndex = 0
|
||||
} else if (firstPlayerSocketId === socketId) {
|
||||
// Le "premier joueur" est justement celui qui part : reste sur le
|
||||
// même index (le joueur suivant a glissé à cette place), en bornant
|
||||
// à la nouvelle taille du tableau.
|
||||
this.firstPlayerIndex = this.firstPlayerIndex % this.players.length
|
||||
} else {
|
||||
// Retrouver la nouvelle position du même joueur après le filter().
|
||||
const newIndex = this.players.findIndex(p => p.socketId === firstPlayerSocketId)
|
||||
this.firstPlayerIndex = newIndex !== -1 ? newIndex : 0
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Démarrage ──────────────────────────────────────────────────────────────
|
||||
@@ -55,10 +94,14 @@ class Room {
|
||||
p.hand = dealt[i].hand
|
||||
p.deck = dealt[i].deck
|
||||
p.gauges = { food: 0, war: 0, health: 0, diplo: 0 }
|
||||
p.items = []
|
||||
})
|
||||
this.currentTurn = 1
|
||||
this.phase = 'selecting'
|
||||
this.pendingPlays.clear()
|
||||
this.pendingBuys.clear()
|
||||
this.availableItems = ITEMS.map(i => i.id)
|
||||
this.firstPlayerIndex = 0
|
||||
}
|
||||
|
||||
// ─── Phase de jeu ───────────────────────────────────────────────────────────
|
||||
@@ -85,11 +128,53 @@ class Room {
|
||||
return this.pendingPlays.size === this.players.length
|
||||
}
|
||||
|
||||
// ─── Achat d'objets ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tente un achat d'objet pour un joueur, pendant la phase 'selecting' (avant
|
||||
* résolution). L'achat est différé (pendingBuys) jusqu'à resolveRound() afin
|
||||
* de départager les conflits (plusieurs joueurs visant le même objet le même
|
||||
* tour) selon la priorité du "premier joueur" tournant.
|
||||
*/
|
||||
buyItem(socketId, itemId) {
|
||||
if (this.phase !== 'selecting')
|
||||
return { ok: false, error: 'Ce n\'est pas la phase de sélection' }
|
||||
|
||||
const player = this.players.find(p => p.socketId === socketId)
|
||||
if (!player) return { ok: false, error: 'Joueur introuvable' }
|
||||
|
||||
const item = getItemById(itemId)
|
||||
if (!item) return { ok: false, error: 'Objet inconnu' }
|
||||
|
||||
if (!this.availableItems.includes(itemId))
|
||||
return { ok: false, error: 'Cet objet a déjà été acheté par un autre joueur' }
|
||||
|
||||
if (player.items.includes(itemId))
|
||||
return { ok: false, error: 'Tu possèdes déjà cet objet' }
|
||||
|
||||
if (this.pendingBuys.has(socketId))
|
||||
return { ok: false, error: 'Tu as déjà tenté un achat ce tour' }
|
||||
|
||||
if (player.gauges[item.attr] < item.cost)
|
||||
return { ok: false, error: `Pas assez de points (${item.attr}) pour acheter ${item.name}` }
|
||||
|
||||
this.pendingBuys.set(socketId, itemId)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ─── Résolution du tour ─────────────────────────────────────────────────────
|
||||
|
||||
resolveRound() {
|
||||
const n = this.players.length
|
||||
// Construire un tableau ordonné des cartes jouées (même ordre que this.players)
|
||||
|
||||
// ── 1. Résoudre les achats en attente (priorité au "premier joueur") ──────
|
||||
// Choix de design assumé : un objet acheté CE tour bénéficie déjà au
|
||||
// résultat de CE même tour (son effet est appliqué juste après, à
|
||||
// l'étape 4, sur les gains calculés à l'étape 3). L'achat n'est donc
|
||||
// pas différé au tour suivant — c'est intentionnel, pas un oubli.
|
||||
const boughtItems = this._resolvePendingBuys()
|
||||
|
||||
// ── 2. Construire un tableau ordonné des cartes jouées ─────────────────────
|
||||
const plays = this.players.map(p => ({
|
||||
player: p,
|
||||
card: this.pendingPlays.get(p.socketId)?.card ?? null
|
||||
@@ -107,10 +192,10 @@ class Room {
|
||||
diplo: card?.diplo ?? 0
|
||||
}))
|
||||
|
||||
// Gains par joueur et par attribut
|
||||
// ── 3. Gains de base par joueur et par attribut ────────────────────────────
|
||||
// Règle : je compare mon attribut à celui du VOISIN DE GAUCHE (index - 1, modulo n)
|
||||
// gain = max(0, moi - voisin_gauche)
|
||||
const gains = plays.map((play, i) => {
|
||||
const baseGains = plays.map((play, i) => {
|
||||
const leftIndex = (i - 1 + n) % n
|
||||
const leftCard = plays[leftIndex].card
|
||||
|
||||
@@ -121,7 +206,10 @@ class Room {
|
||||
return g
|
||||
})
|
||||
|
||||
// Appliquer les gains aux jauges
|
||||
// ── 4. Appliquer les effets permanents des objets possédés ─────────────────
|
||||
const gains = this._applyItemEffects(baseGains)
|
||||
|
||||
// Appliquer les gains finaux aux jauges
|
||||
plays.forEach(({ player }, i) => {
|
||||
ATTRS.forEach(attr => {
|
||||
player.gauges[attr] += gains[i][attr]
|
||||
@@ -140,7 +228,101 @@ class Room {
|
||||
this.pendingPlays.clear()
|
||||
this.phase = 'resolve'
|
||||
|
||||
return { playedCards, gains, message: lines.join(' | ') }
|
||||
return { playedCards, gains, boughtItems, message: lines.join(' | ') }
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout les tentatives d'achat en attente (this.pendingBuys) : en cas de
|
||||
* conflit sur un même objet (stock unique), seul le joueur le plus proche
|
||||
* du "premier joueur" (this.firstPlayerIndex, dans l'ordre this.players)
|
||||
* l'obtient. Déduit le coût de la jauge du gagnant et retire l'objet du
|
||||
* stock disponible. Retourne la liste des achats effectivement réalisés.
|
||||
*/
|
||||
_resolvePendingBuys() {
|
||||
if (this.pendingBuys.size === 0) return []
|
||||
|
||||
const n = this.players.length
|
||||
// Ordre de priorité : du premier joueur du tour jusqu'au précédent, en boucle
|
||||
const priorityOrder = []
|
||||
for (let k = 0; k < n; k++) {
|
||||
priorityOrder.push((this.firstPlayerIndex + k) % n)
|
||||
}
|
||||
|
||||
// Regrouper les tentatives par objet visé
|
||||
const attemptsByItem = new Map() // itemId -> [socketId, ...]
|
||||
this.pendingBuys.forEach((itemId, socketId) => {
|
||||
if (!attemptsByItem.has(itemId)) attemptsByItem.set(itemId, [])
|
||||
attemptsByItem.get(itemId).push(socketId)
|
||||
})
|
||||
|
||||
const bought = []
|
||||
|
||||
attemptsByItem.forEach((socketIds, itemId) => {
|
||||
if (!this.availableItems.includes(itemId)) return // déjà pris entre-temps (ne devrait pas arriver)
|
||||
|
||||
// Trier les prétendants selon l'ordre de priorité du tour
|
||||
const winnerSocketId = socketIds
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const idxA = priorityOrder.indexOf(this.players.findIndex(p => p.socketId === a))
|
||||
const idxB = priorityOrder.indexOf(this.players.findIndex(p => p.socketId === b))
|
||||
return idxA - idxB
|
||||
})[0]
|
||||
|
||||
const winner = this.players.find(p => p.socketId === winnerSocketId)
|
||||
const item = getItemById(itemId)
|
||||
if (!winner || !item) return
|
||||
|
||||
// Sécurité : re-vérifier que le gagnant a toujours assez de points
|
||||
if (winner.gauges[item.attr] < item.cost) return
|
||||
|
||||
winner.gauges[item.attr] -= item.cost
|
||||
winner.items.push(itemId)
|
||||
this.availableItems = this.availableItems.filter(id => id !== itemId)
|
||||
|
||||
bought.push({ playerName: winner.name, teamIndex: winner.teamIndex, itemId, itemName: item.name })
|
||||
})
|
||||
|
||||
this.pendingBuys.clear()
|
||||
return bought
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique les effets permanents des objets possédés sur les gains de base
|
||||
* d'un tour, et retourne un nouveau tableau de gains (même forme/ordre que
|
||||
* baseGains, un objet { food, war, health, diplo } par joueur).
|
||||
*/
|
||||
_applyItemEffects(baseGains) {
|
||||
const gains = baseGains.map(g => ({ ...g }))
|
||||
|
||||
this.players.forEach((player, i) => {
|
||||
player.items.forEach(itemId => {
|
||||
const item = getItemById(itemId)
|
||||
if (!item) return
|
||||
|
||||
if (item.effect === 'double_gain') {
|
||||
gains[i][item.attr] = baseGains[i][item.attr] * 2
|
||||
}
|
||||
|
||||
if (item.effect === 'guaranteed_gain') {
|
||||
gains[i][item.attr] = Math.max(baseGains[i][item.attr], item.effectValue)
|
||||
}
|
||||
|
||||
if (item.effect === 'copy_best_in_game') {
|
||||
// Design voulu (Sceau Royal) : "Le meilleur" = le gain le plus élevé
|
||||
// sur cet attribut parmi TOUS les autres joueurs de la partie ce
|
||||
// tour-ci — pas seulement les voisins immédiats. Effet volontairement
|
||||
// plus fort qu'une simple comparaison de voisinage.
|
||||
const bestOther = this.players.reduce((max, other, j) => {
|
||||
if (j === i) return max
|
||||
return Math.max(max, baseGains[j][item.attr])
|
||||
}, 0)
|
||||
gains[i][item.attr] = Math.max(baseGains[i][item.attr], bestOther)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return gains
|
||||
}
|
||||
|
||||
// ─── Tour suivant ────────────────────────────────────────────────────────────
|
||||
@@ -153,12 +335,18 @@ class Room {
|
||||
}
|
||||
})
|
||||
|
||||
// Le "premier joueur" (priorité d'achat) tourne à chaque tour
|
||||
if (this.players.length > 0) {
|
||||
this.firstPlayerIndex = (this.firstPlayerIndex + 1) % this.players.length
|
||||
}
|
||||
|
||||
this.currentTurn++
|
||||
if (this.isGameOver()) {
|
||||
this.phase = 'end'
|
||||
return
|
||||
}
|
||||
this.phase = 'selecting'
|
||||
this.pendingBuys.clear()
|
||||
}
|
||||
|
||||
isGameOver() {
|
||||
@@ -176,7 +364,8 @@ class Room {
|
||||
gauges: { ...p.gauges },
|
||||
score: ATTRS.reduce((sum, a) => sum + p.gauges[a], 0),
|
||||
handSize: p.hand.length,
|
||||
deckSize: p.deck.length
|
||||
deckSize: p.deck.length,
|
||||
items: [...p.items]
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -184,14 +373,19 @@ class Room {
|
||||
const player = this.players.find(p => p.socketId === socketId)
|
||||
const hasPlayed = this.pendingPlays.has(socketId)
|
||||
const personalPhase = this.phase === 'selecting' && hasPlayed ? 'waiting' : this.phase
|
||||
const firstPlayer = this.players[this.firstPlayerIndex]
|
||||
|
||||
return {
|
||||
players: this.getPlayersInfo(),
|
||||
currentTurn: this.currentTurn,
|
||||
maxTurns: this.maxTurns,
|
||||
phase: personalPhase,
|
||||
hand: player ? player.hand : [],
|
||||
myTeamIndex: player ? player.teamIndex : 0
|
||||
players: this.getPlayersInfo(),
|
||||
currentTurn: this.currentTurn,
|
||||
maxTurns: this.maxTurns,
|
||||
phase: personalPhase,
|
||||
hand: player ? player.hand : [],
|
||||
myTeamIndex: player ? player.teamIndex : 0,
|
||||
myItems: player ? [...player.items] : [],
|
||||
pendingBuyItemId: this.pendingBuys.get(socketId) ?? null,
|
||||
availableItems: [...this.availableItems],
|
||||
firstPlayerName: firstPlayer ? firstPlayer.name : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Regnum - Définition des objets achetables
|
||||
*
|
||||
* Chaque objet est lié à un attribut (food, war, health, diplo), coûte des
|
||||
* points de la jauge correspondante à l'achat, et procure un effet PERMANENT
|
||||
* une fois acheté (appliqué à chaque résolution de tour tant que le joueur
|
||||
* le possède).
|
||||
*
|
||||
* Stock : chaque objet n'existe qu'en UN SEUL exemplaire pour toute la partie.
|
||||
* Une fois acheté par un joueur, plus personne d'autre ne peut l'acheter.
|
||||
*/
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
id: 'trebuchet',
|
||||
name: 'Trébuchet',
|
||||
attr: 'war',
|
||||
cost: 3,
|
||||
effect: 'double_gain',
|
||||
description: 'Double le gain de Guerre obtenu à chaque tour.'
|
||||
},
|
||||
{
|
||||
id: 'plow',
|
||||
name: 'Charrue',
|
||||
attr: 'food',
|
||||
cost: 3,
|
||||
effect: 'double_gain',
|
||||
description: 'Double le gain de Nourriture obtenu à chaque tour.'
|
||||
},
|
||||
{
|
||||
id: 'medkit',
|
||||
name: 'Trousse du Médecin',
|
||||
attr: 'health',
|
||||
cost: 3,
|
||||
effect: 'guaranteed_gain',
|
||||
effectValue: 2,
|
||||
description: 'Garantit +2 en Santé à chaque tour, quel que soit le résultat de la comparaison.'
|
||||
},
|
||||
{
|
||||
id: 'royal_seal',
|
||||
name: 'Sceau Royal',
|
||||
attr: 'diplo',
|
||||
cost: 3,
|
||||
effect: 'copy_best_in_game',
|
||||
description: 'Copie à chaque tour le meilleur gain de Diplomatie parmi tous les autres joueurs.'
|
||||
}
|
||||
]
|
||||
|
||||
function getItemById(id) {
|
||||
return ITEMS.find(i => i.id === id) ?? null
|
||||
}
|
||||
|
||||
module.exports = { ITEMS, getItemById }
|
||||
@@ -58,6 +58,24 @@ io.on('connection', (socket) => {
|
||||
socket.emit('game-state', room.getStateFor(socket.id))
|
||||
})
|
||||
|
||||
// ─── Acheter un objet ────────────────────────────────────────────────────────
|
||||
socket.on('buy-item', ({ roomId, itemId }) => {
|
||||
const room = rooms.get(roomId)
|
||||
if (!room) return
|
||||
|
||||
const result = room.buyItem(socket.id, itemId)
|
||||
if (!result.ok) {
|
||||
socket.emit('error', { message: result.error })
|
||||
return
|
||||
}
|
||||
|
||||
// L'achat est différé (résolu avec le tour) : on renvoie juste l'état à
|
||||
// jour à chaque joueur pour refléter la tentative en attente.
|
||||
room.players.forEach(player => {
|
||||
io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId))
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Jouer une carte ─────────────────────────────────────────────────────────
|
||||
socket.on('play-card', ({ roomId, cardId }) => {
|
||||
const room = rooms.get(roomId)
|
||||
@@ -87,19 +105,18 @@ io.on('connection', (socket) => {
|
||||
const roundResult = room.resolveRound()
|
||||
console.log(`[${roomId}] Tour ${room.currentTurn} résolu`)
|
||||
|
||||
// Révéler les cartes jouées et les gains par attribut
|
||||
// Révéler les cartes jouées, les gains par attribut, et les objets achetés
|
||||
io.to(roomId).emit('round-result', {
|
||||
playedCards: roundResult.playedCards,
|
||||
gains: roundResult.gains,
|
||||
boughtItems: roundResult.boughtItems,
|
||||
message: roundResult.message
|
||||
})
|
||||
|
||||
// Mise à jour scores
|
||||
io.to(roomId).emit('game-state', {
|
||||
players: room.getPlayersInfo(),
|
||||
currentTurn: room.currentTurn,
|
||||
maxTurns: room.maxTurns,
|
||||
phase: 'resolve'
|
||||
// Mise à jour scores (état personnalisé par joueur : items, achat en
|
||||
// attente, objets encore disponibles, etc.)
|
||||
room.players.forEach(player => {
|
||||
io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId))
|
||||
})
|
||||
|
||||
// 3 secondes d'affichage puis tour suivant / fin
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { Room } = require('../../src/game/Room')
|
||||
|
||||
/** Aide : crée une Room avec n joueurs déjà ajoutés et la partie démarrée. */
|
||||
function makeStartedRoom(n) {
|
||||
const room = new Room('T')
|
||||
for (let i = 0; i < n; i++) {
|
||||
room.addPlayer(`s${i}`, `P${i}`)
|
||||
}
|
||||
room.startGame()
|
||||
return room
|
||||
}
|
||||
|
||||
/** Aide : force la carte en tête de main d'un joueur à des valeurs contrôlées. */
|
||||
function setHandCardValues(room, socketId, values) {
|
||||
const player = room.players.find(p => p.socketId === socketId)
|
||||
Object.assign(player.hand[0], values)
|
||||
return player.hand[0]
|
||||
}
|
||||
|
||||
describe('Room - gestion des joueurs', () => {
|
||||
test('addPlayer ajoute un joueur avec jauges à 0 et teamIndex incrémental', () => {
|
||||
const room = new Room('R1')
|
||||
assert.equal(room.addPlayer('s1', 'Alice'), true)
|
||||
assert.equal(room.addPlayer('s2', 'Bob'), true)
|
||||
|
||||
assert.equal(room.players.length, 2)
|
||||
assert.equal(room.players[0].teamIndex, 0)
|
||||
assert.equal(room.players[1].teamIndex, 1)
|
||||
assert.deepEqual(room.players[0].gauges, { food: 0, war: 0, health: 0, diplo: 0 })
|
||||
assert.deepEqual(room.players[0].items, [])
|
||||
})
|
||||
|
||||
test('addPlayer refuse un 5e joueur (MAX_PLAYERS = 4)', () => {
|
||||
const room = new Room('R2')
|
||||
for (let i = 0; i < 4; i++) assert.equal(room.addPlayer(`s${i}`, `P${i}`), true)
|
||||
assert.equal(room.addPlayer('s4', 'Trop'), false)
|
||||
assert.equal(room.players.length, 4)
|
||||
})
|
||||
|
||||
test('addPlayer est idempotent pour un socketId déjà présent (reconnexion)', () => {
|
||||
const room = new Room('R3')
|
||||
room.addPlayer('s1', 'Alice')
|
||||
const result = room.addPlayer('s1', 'Alice')
|
||||
assert.equal(result, true)
|
||||
assert.equal(room.players.length, 1)
|
||||
})
|
||||
|
||||
test('removePlayer retire le joueur de la liste', () => {
|
||||
const room = new Room('R4')
|
||||
room.addPlayer('s1', 'Alice')
|
||||
room.addPlayer('s2', 'Bob')
|
||||
room.removePlayer('s1')
|
||||
assert.equal(room.players.length, 1)
|
||||
assert.equal(room.players[0].socketId, 's2')
|
||||
})
|
||||
|
||||
test('removePlayer nettoie les achats/cartes en attente du joueur parti', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
room.players[0].gauges.food = 10
|
||||
room.buyItem('s0', 'plow')
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
|
||||
room.removePlayer('s0')
|
||||
|
||||
assert.equal(room.pendingBuys.has('s0'), false)
|
||||
// s1 avait joué une carte, removePlayer ne doit pas y toucher (ce n'est pas lui qui part)
|
||||
assert.equal(room.pendingPlays.has('s1'), true)
|
||||
})
|
||||
|
||||
test('removePlayer conserve l\'identité du "premier joueur" après un décalage d\'index', () => {
|
||||
const room = makeStartedRoom(4)
|
||||
// firstPlayerIndex pointe sur s0 ; on retire s0's voisin s1 (index 1, avant s0 dans la liste)
|
||||
room.firstPlayerIndex = 2 // pointe sur s2
|
||||
room.removePlayer('s1') // s1 est retiré, s2 glisse à l'index 1
|
||||
|
||||
const firstPlayer = room.players[room.firstPlayerIndex]
|
||||
assert.equal(firstPlayer.socketId, 's2', 'le premier joueur doit rester s2 malgré le décalage')
|
||||
})
|
||||
|
||||
test('removePlayer garde firstPlayerIndex borné si c\'est le premier joueur lui-même qui part', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
room.firstPlayerIndex = 2 // dernier joueur (s2)
|
||||
room.removePlayer('s2')
|
||||
|
||||
assert.ok(room.firstPlayerIndex < room.players.length, 'index doit rester dans les bornes')
|
||||
})
|
||||
|
||||
test('removePlayer ramène firstPlayerIndex à 0 si tous les joueurs partent', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.removePlayer('s0')
|
||||
room.removePlayer('s1')
|
||||
assert.equal(room.players.length, 0)
|
||||
assert.equal(room.firstPlayerIndex, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - startGame', () => {
|
||||
test('distribue une main de 4 cartes et remet les jauges/items à zéro', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
assert.equal(room.currentTurn, 1)
|
||||
assert.equal(room.phase, 'selecting')
|
||||
for (const p of room.players) {
|
||||
assert.equal(p.hand.length, 4)
|
||||
assert.equal(p.deck.length, 8)
|
||||
assert.deepEqual(p.gauges, { food: 0, war: 0, health: 0, diplo: 0 })
|
||||
assert.deepEqual(p.items, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('réinitialise le stock d\'objets disponibles et la priorité d\'achat', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
assert.equal(room.availableItems.length, 4)
|
||||
assert.equal(room.firstPlayerIndex, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - playCard', () => {
|
||||
test('retire la carte de la main et l\'enregistre en pendingPlays', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
const card = room.players[0].hand[0]
|
||||
const result = room.playCard('s0', card.id)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(room.players[0].hand.length, 3)
|
||||
assert.equal(room.pendingPlays.get('s0').card.id, card.id)
|
||||
})
|
||||
|
||||
test('refuse de jouer hors phase selecting', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.phase = 'resolve'
|
||||
const result = room.playCard('s0', room.players[0].hand[0].id)
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('refuse un second coup dans le même tour', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
const [c1, c2] = room.players[0].hand
|
||||
room.playCard('s0', c1.id)
|
||||
const result = room.playCard('s0', c2.id)
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('refuse une carte absente de la main', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
const result = room.playCard('s0', 'carte-inexistante')
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('refuse un joueur inconnu', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
const result = room.playCard('ghost', 'whatever')
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('allPlayersPlayed reflète le nombre de coups joués', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
assert.equal(room.allPlayersPlayed(), false)
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
assert.equal(room.allPlayersPlayed(), false)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
assert.equal(room.allPlayersPlayed(), true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - buyItem (validations)', () => {
|
||||
test('accepte un achat si la jauge est suffisante', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 5
|
||||
const result = room.buyItem('s0', 'trebuchet')
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(room.pendingBuys.get('s0'), 'trebuchet')
|
||||
})
|
||||
|
||||
test('refuse si jauge insuffisante', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 1
|
||||
const result = room.buyItem('s0', 'trebuchet')
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /Pas assez de points/)
|
||||
})
|
||||
|
||||
test('refuse hors phase selecting', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.phase = 'resolve'
|
||||
const result = room.buyItem('s0', 'trebuchet')
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
|
||||
test('refuse un objet inconnu', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
const result = room.buyItem('s0', 'objet-inexistant')
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /inconnu/)
|
||||
})
|
||||
|
||||
test('refuse un objet déjà retiré du stock (availableItems)', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.availableItems = room.availableItems.filter(id => id !== 'trebuchet')
|
||||
room.players[0].gauges.war = 10
|
||||
const result = room.buyItem('s0', 'trebuchet')
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /déjà été acheté/)
|
||||
})
|
||||
|
||||
test('refuse un objet déjà possédé par ce joueur', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].items.push('trebuchet')
|
||||
room.players[0].gauges.war = 10
|
||||
const result = room.buyItem('s0', 'trebuchet')
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /possèdes déjà/)
|
||||
})
|
||||
|
||||
test('refuse une seconde tentative d\'achat le même tour', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 10
|
||||
room.players[0].gauges.food = 10
|
||||
room.buyItem('s0', 'trebuchet')
|
||||
const result = room.buyItem('s0', 'plow')
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /déjà tenté/)
|
||||
})
|
||||
|
||||
test('refuse un joueur inconnu', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
const result = room.buyItem('ghost', 'trebuchet')
|
||||
assert.equal(result.ok, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - resolveRound : calcul des gains de base', () => {
|
||||
test('le gain = max(0, ma_valeur - valeur_voisin_gauche)', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
// Index 0 : voisin de gauche = index 1 (n=2, (0-1+2)%2 = 1)
|
||||
setHandCardValues(room, 's0', { war: 5 })
|
||||
setHandCardValues(room, 's1', { war: 2 })
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
// s0 vs voisin gauche s1 : max(0, 5-2) = 3
|
||||
assert.equal(result.gains[0].war, 3)
|
||||
// s1 vs voisin gauche s0 : max(0, 2-5) = 0
|
||||
assert.equal(result.gains[1].war, 0)
|
||||
})
|
||||
|
||||
test('un gain négatif est clampé à 0, jamais retiré de la jauge', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
setHandCardValues(room, 's0', { food: 1 })
|
||||
setHandCardValues(room, 's1', { food: 6 })
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.resolveRound()
|
||||
|
||||
assert.equal(room.players[0].gauges.food, 0)
|
||||
})
|
||||
|
||||
test('applique bien les gains cumulés aux jauges des joueurs', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
setHandCardValues(room, 's0', { health: 4 })
|
||||
setHandCardValues(room, 's1', { health: 1 })
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.resolveRound()
|
||||
|
||||
assert.equal(room.players[0].gauges.health, 3)
|
||||
})
|
||||
|
||||
test('vide pendingPlays et passe en phase resolve', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.resolveRound()
|
||||
|
||||
assert.equal(room.pendingPlays.size, 0)
|
||||
assert.equal(room.phase, 'resolve')
|
||||
})
|
||||
|
||||
test('gère un joueur n\'ayant pas joué de carte (card=null) sans planter', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
// s1 ne joue rien
|
||||
const result = room.resolveRound()
|
||||
|
||||
assert.equal(result.playedCards[1].cardId, undefined)
|
||||
assert.equal(result.gains[1].food, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - resolveRound : effets permanents des objets', () => {
|
||||
test('double_gain (Trébuchet) double le gain de guerre', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].items.push('trebuchet')
|
||||
setHandCardValues(room, 's0', { war: 4 })
|
||||
setHandCardValues(room, 's1', { war: 1 })
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
// gain de base = 4-1 = 3, doublé par Trébuchet = 6
|
||||
assert.equal(result.gains[0].war, 6)
|
||||
})
|
||||
|
||||
test('guaranteed_gain (Trousse) garantit un minimum même si le gain de base est plus faible', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].items.push('medkit')
|
||||
setHandCardValues(room, 's0', { health: 1 })
|
||||
setHandCardValues(room, 's1', { health: 6 }) // gain de base = 0
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
const medkit = require('../../src/game/items').getItemById('medkit')
|
||||
assert.equal(result.gains[0].health, medkit.effectValue)
|
||||
})
|
||||
|
||||
test('guaranteed_gain n\'écrase pas un gain de base déjà supérieur au minimum', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].items.push('medkit')
|
||||
setHandCardValues(room, 's0', { health: 6 })
|
||||
setHandCardValues(room, 's1', { health: 1 }) // gain de base = 5, > effectValue (2)
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
assert.equal(result.gains[0].health, 5)
|
||||
})
|
||||
|
||||
test('copy_best_in_game (Sceau Royal) copie le meilleur gain diplo de tous les autres joueurs', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
room.players[0].items.push('royal_seal')
|
||||
setHandCardValues(room, 's0', { diplo: 0 })
|
||||
setHandCardValues(room, 's1', { diplo: 0 })
|
||||
setHandCardValues(room, 's2', { diplo: 0 })
|
||||
// Forcer un gros gain diplo pour s1 en jouant une carte à forte diplo
|
||||
// contre le voisin de gauche de s1 (s0, diplo=0 car sa carte a diplo=0)
|
||||
room.players[1].hand[0].diplo = 6
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.playCard('s2', room.players[2].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
// s1 vs voisin gauche s0 (diplo 0) : gain de base = 6
|
||||
assert.equal(result.gains[1].diplo, 6)
|
||||
// s0 possède le Sceau : copie le meilleur gain diplo parmi s1/s2 = 6
|
||||
assert.equal(result.gains[0].diplo, 6)
|
||||
})
|
||||
|
||||
test('copy_best_in_game ne dégrade jamais le gain de base du porteur', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
room.players[0].items.push('royal_seal')
|
||||
setHandCardValues(room, 's0', { diplo: 6 })
|
||||
setHandCardValues(room, 's1', { diplo: 0 })
|
||||
setHandCardValues(room, 's2', { diplo: 0 })
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.playCard('s2', room.players[2].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
// s0 a déjà le meilleur gain de base possible, le Sceau ne doit pas le réduire
|
||||
assert.ok(result.gains[0].diplo >= 6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - résolution des achats en attente (priorité, stock unique)', () => {
|
||||
test('un achat en attente est débité de la jauge et ajouté aux items du joueur', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 5
|
||||
room.buyItem('s0', 'trebuchet')
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
assert.equal(room.players[0].items.includes('trebuchet'), true)
|
||||
assert.equal(room.availableItems.includes('trebuchet'), false)
|
||||
assert.equal(result.boughtItems.length, 1)
|
||||
assert.equal(result.boughtItems[0].itemId, 'trebuchet')
|
||||
})
|
||||
|
||||
test('déduit uniquement le coût de l\'objet, pas plus', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 10
|
||||
room.buyItem('s0', 'trebuchet')
|
||||
setHandCardValues(room, 's0', { war: 0 })
|
||||
setHandCardValues(room, 's1', { war: 0 })
|
||||
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.resolveRound()
|
||||
|
||||
const trebuchet = require('../../src/game/items').getItemById('trebuchet')
|
||||
assert.equal(room.players[0].gauges.war, 10 - trebuchet.cost)
|
||||
})
|
||||
|
||||
test('en cas de conflit sur le même objet, le "premier joueur" du tour gagne', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.food = 10
|
||||
room.players[1].gauges.food = 10
|
||||
room.firstPlayerIndex = 0 // s0 est prioritaire
|
||||
|
||||
room.buyItem('s0', 'plow')
|
||||
room.buyItem('s1', 'plow')
|
||||
|
||||
// Neutraliser l'attribut testé (food) sur les cartes jouées pour isoler
|
||||
// le coût d'achat du gain de carte normal (sinon la comparaison de gain
|
||||
// voisin-de-gauche ajouterait aussi des points à cette même jauge).
|
||||
setHandCardValues(room, 's0', { food: 0 })
|
||||
setHandCardValues(room, 's1', { food: 0 })
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
|
||||
assert.equal(result.boughtItems.length, 1)
|
||||
assert.equal(result.boughtItems[0].playerName, 'P0')
|
||||
assert.equal(room.players[0].items.includes('plow'), true)
|
||||
assert.equal(room.players[1].items.includes('plow'), false)
|
||||
// Le perdant ne paie rien
|
||||
assert.equal(room.players[1].gauges.food, 10)
|
||||
})
|
||||
|
||||
test('en cas de conflit, la priorité suit firstPlayerIndex même si ce n\'est pas le joueur 0', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
room.players[0].gauges.food = 10
|
||||
room.players[1].gauges.food = 10
|
||||
room.players[2].gauges.food = 10
|
||||
room.firstPlayerIndex = 2 // s2 est prioritaire ce tour
|
||||
|
||||
room.buyItem('s0', 'plow')
|
||||
room.buyItem('s1', 'plow')
|
||||
room.buyItem('s2', 'plow')
|
||||
|
||||
room.players.forEach(p => room.playCard(p.socketId, p.hand[0].id))
|
||||
const result = room.resolveRound()
|
||||
|
||||
assert.equal(result.boughtItems[0].playerName, 'P2')
|
||||
})
|
||||
|
||||
test('chaque objet ne peut être acheté qu\'une seule fois pour toute la partie', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 10
|
||||
room.buyItem('s0', 'trebuchet')
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.resolveRound()
|
||||
|
||||
room.nextTurn()
|
||||
room.players[1].gauges.war = 10
|
||||
const secondAttempt = room.buyItem('s1', 'trebuchet')
|
||||
assert.equal(secondAttempt.ok, false)
|
||||
})
|
||||
|
||||
test('pas d\'achat en attente -> boughtItems vide, aucune jauge modifiée', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
const result = room.resolveRound()
|
||||
assert.deepEqual(result.boughtItems, [])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - nextTurn', () => {
|
||||
test('chaque joueur pioche une carte de son deck', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
room.playCard('s1', room.players[1].hand[0].id)
|
||||
room.resolveRound()
|
||||
|
||||
const deckSizeBefore = room.players[0].deck.length
|
||||
const handSizeBefore = room.players[0].hand.length
|
||||
room.nextTurn()
|
||||
|
||||
assert.equal(room.players[0].deck.length, deckSizeBefore - 1)
|
||||
assert.equal(room.players[0].hand.length, handSizeBefore + 1)
|
||||
})
|
||||
|
||||
test('fait tourner firstPlayerIndex de façon circulaire', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
assert.equal(room.firstPlayerIndex, 0)
|
||||
room.nextTurn()
|
||||
assert.equal(room.firstPlayerIndex, 1)
|
||||
room.nextTurn()
|
||||
assert.equal(room.firstPlayerIndex, 2)
|
||||
room.nextTurn()
|
||||
assert.equal(room.firstPlayerIndex, 0)
|
||||
})
|
||||
|
||||
test('incrémente currentTurn et repasse en phase selecting', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.nextTurn()
|
||||
assert.equal(room.currentTurn, 2)
|
||||
assert.equal(room.phase, 'selecting')
|
||||
})
|
||||
|
||||
test('vide pendingBuys au tour suivant', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 1 // insuffisant, mais peu importe pour ce test
|
||||
room.pendingBuys.set('s0', 'trebuchet')
|
||||
room.nextTurn()
|
||||
assert.equal(room.pendingBuys.size, 0)
|
||||
})
|
||||
|
||||
test('passe en phase end quand currentTurn dépasse maxTurns', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.currentTurn = room.maxTurns
|
||||
room.nextTurn()
|
||||
assert.equal(room.phase, 'end')
|
||||
})
|
||||
|
||||
test('isGameOver devient vrai quand mains ET decks sont vides', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players.forEach(p => { p.hand = []; p.deck = [] })
|
||||
assert.equal(room.isGameOver(), true)
|
||||
})
|
||||
|
||||
test('isGameOver est faux tant qu\'il reste des cartes en main ou en deck', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
assert.equal(room.isGameOver(), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Room - données publiques (getPlayersInfo / getStateFor)', () => {
|
||||
test('getPlayersInfo calcule le score comme la somme des 4 jauges', () => {
|
||||
const room = makeStartedRoom(1)
|
||||
room.players[0].gauges = { food: 1, war: 2, health: 3, diplo: 4 }
|
||||
const info = room.getPlayersInfo()
|
||||
assert.equal(info[0].score, 10)
|
||||
})
|
||||
|
||||
test('getPlayersInfo retourne des copies (pas de référence directe aux gauges/items internes)', () => {
|
||||
const room = makeStartedRoom(1)
|
||||
const info = room.getPlayersInfo()
|
||||
info[0].gauges.food = 999
|
||||
info[0].items.push('hack')
|
||||
assert.equal(room.players[0].gauges.food, 0)
|
||||
assert.deepEqual(room.players[0].items, [])
|
||||
})
|
||||
|
||||
test('getStateFor renvoie phase="waiting" pour un joueur ayant déjà joué, "selecting" pour les autres', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.playCard('s0', room.players[0].hand[0].id)
|
||||
|
||||
assert.equal(room.getStateFor('s0').phase, 'waiting')
|
||||
assert.equal(room.getStateFor('s1').phase, 'selecting')
|
||||
})
|
||||
|
||||
test('getStateFor expose pendingBuyItemId propre à chaque joueur', () => {
|
||||
const room = makeStartedRoom(2)
|
||||
room.players[0].gauges.war = 5
|
||||
room.buyItem('s0', 'trebuchet')
|
||||
|
||||
assert.equal(room.getStateFor('s0').pendingBuyItemId, 'trebuchet')
|
||||
assert.equal(room.getStateFor('s1').pendingBuyItemId, null)
|
||||
})
|
||||
|
||||
test('getStateFor expose firstPlayerName correspondant à firstPlayerIndex', () => {
|
||||
const room = makeStartedRoom(3)
|
||||
room.firstPlayerIndex = 1
|
||||
assert.equal(room.getStateFor('s0').firstPlayerName, 'P1')
|
||||
})
|
||||
|
||||
test('getStateFor gère un socketId inconnu sans planter (spectateur/déconnecté)', () => {
|
||||
const state = makeStartedRoom(2).getStateFor('ghost')
|
||||
assert.deepEqual(state.hand, [])
|
||||
assert.deepEqual(state.myItems, [])
|
||||
assert.equal(state.pendingBuyItemId, null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { RoomManager } = require('../../src/game/RoomManager')
|
||||
const { Room } = require('../../src/game/Room')
|
||||
|
||||
describe('RoomManager', () => {
|
||||
test('getOrCreate crée une nouvelle Room si elle n\'existe pas', () => {
|
||||
const manager = new RoomManager()
|
||||
const room = manager.getOrCreate('abc')
|
||||
assert.ok(room instanceof Room)
|
||||
assert.equal(room.id, 'abc')
|
||||
})
|
||||
|
||||
test('getOrCreate retourne la même instance pour un id déjà créé', () => {
|
||||
const manager = new RoomManager()
|
||||
const room1 = manager.getOrCreate('abc')
|
||||
const room2 = manager.getOrCreate('abc')
|
||||
assert.equal(room1, room2)
|
||||
})
|
||||
|
||||
test('get retourne null pour un id inconnu', () => {
|
||||
const manager = new RoomManager()
|
||||
assert.equal(manager.get('unknown'), null)
|
||||
})
|
||||
|
||||
test('get retourne la room existante après getOrCreate', () => {
|
||||
const manager = new RoomManager()
|
||||
manager.getOrCreate('xyz')
|
||||
assert.ok(manager.get('xyz') instanceof Room)
|
||||
})
|
||||
|
||||
test('delete retire la room de la map', () => {
|
||||
const manager = new RoomManager()
|
||||
manager.getOrCreate('to-delete')
|
||||
manager.delete('to-delete')
|
||||
assert.equal(manager.get('to-delete'), null)
|
||||
})
|
||||
|
||||
test('delete sur un id inconnu ne lève pas d\'erreur', () => {
|
||||
const manager = new RoomManager()
|
||||
assert.doesNotThrow(() => manager.delete('never-existed'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { CHARACTERS, TEAM_COLORS, getDeckForTeam, dealHands } = require('../../src/game/cards')
|
||||
|
||||
describe('cards.js', () => {
|
||||
test('expose exactement 12 personnages', () => {
|
||||
assert.equal(CHARACTERS.length, 12)
|
||||
})
|
||||
|
||||
test('chaque personnage a un id unique', () => {
|
||||
const ids = CHARACTERS.map(c => c.id)
|
||||
assert.equal(new Set(ids).size, ids.length)
|
||||
})
|
||||
|
||||
test('chaque personnage a les 4 attributs définis entre 1 et 6', () => {
|
||||
for (const c of CHARACTERS) {
|
||||
for (const attr of ['food', 'war', 'health', 'diplo']) {
|
||||
assert.ok(Number.isInteger(c[attr]), `${c.id}.${attr} doit être un entier`)
|
||||
assert.ok(c[attr] >= 1 && c[attr] <= 6, `${c.id}.${attr} doit être entre 1 et 6`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('getDeckForTeam retourne les 12 personnages avec la couleur d\'équipe', () => {
|
||||
const deck = getDeckForTeam(2)
|
||||
assert.equal(deck.length, 12)
|
||||
for (const card of deck) {
|
||||
assert.equal(card.teamIndex, 2)
|
||||
assert.deepEqual(card.teamColor, TEAM_COLORS[2])
|
||||
}
|
||||
})
|
||||
|
||||
test('getDeckForTeam ne mute pas les CHARACTERS d\'origine', () => {
|
||||
const before = JSON.parse(JSON.stringify(CHARACTERS))
|
||||
getDeckForTeam(0)
|
||||
assert.deepEqual(CHARACTERS, before)
|
||||
})
|
||||
|
||||
describe('dealHands', () => {
|
||||
test('distribue une main de taille handSize et le reste au deck, pour chaque joueur', () => {
|
||||
const result = dealHands(3, 4)
|
||||
assert.equal(result.length, 3)
|
||||
for (const { hand, deck } of result) {
|
||||
assert.equal(hand.length, 4)
|
||||
assert.equal(deck.length, 12 - 4)
|
||||
// Pas de doublons entre hand et deck (même deck de 12 cartes)
|
||||
const allIds = [...hand, ...deck].map(c => c.id)
|
||||
assert.equal(new Set(allIds).size, 12)
|
||||
}
|
||||
})
|
||||
|
||||
test('chaque joueur reçoit un deck mélangé indépendamment (decks non systématiquement identiques)', () => {
|
||||
// Avec un shuffle aléatoire, la probabilité que 4 joueurs aient exactement
|
||||
// la même main de 4 cartes dans le même ordre est infinitésimale.
|
||||
const result = dealHands(4, 4)
|
||||
const handsAsStrings = result.map(r => r.hand.map(c => c.id).join(','))
|
||||
assert.ok(new Set(handsAsStrings).size > 1, 'les mains devraient différer entre joueurs')
|
||||
})
|
||||
|
||||
test('gère handSize par défaut (4) si non fourni', () => {
|
||||
const result = dealHands(2)
|
||||
assert.equal(result[0].hand.length, 4)
|
||||
})
|
||||
|
||||
test('gère playerCount = 0 sans erreur', () => {
|
||||
const result = dealHands(0, 4)
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test, describe } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { ITEMS, getItemById } = require('../../src/game/items')
|
||||
|
||||
describe('items.js', () => {
|
||||
test('expose exactement 4 objets', () => {
|
||||
assert.equal(ITEMS.length, 4)
|
||||
})
|
||||
|
||||
test('chaque objet a un id unique', () => {
|
||||
const ids = ITEMS.map(i => i.id)
|
||||
assert.equal(new Set(ids).size, ids.length)
|
||||
})
|
||||
|
||||
test('chaque objet est lié à un attribut valide et a un coût positif', () => {
|
||||
const validAttrs = ['food', 'war', 'health', 'diplo']
|
||||
for (const item of ITEMS) {
|
||||
assert.ok(validAttrs.includes(item.attr), `${item.id}.attr doit être un attribut valide`)
|
||||
assert.ok(item.cost > 0, `${item.id}.cost doit être positif`)
|
||||
}
|
||||
})
|
||||
|
||||
test('chaque objet a un effect reconnu par Room._applyItemEffects', () => {
|
||||
const knownEffects = ['double_gain', 'guaranteed_gain', 'copy_best_in_game']
|
||||
for (const item of ITEMS) {
|
||||
assert.ok(knownEffects.includes(item.effect), `${item.id}.effect '${item.effect}' inconnu`)
|
||||
}
|
||||
})
|
||||
|
||||
test('guaranteed_gain (Trousse du Médecin) définit bien effectValue', () => {
|
||||
const medkit = getItemById('medkit')
|
||||
assert.ok(medkit)
|
||||
assert.equal(medkit.effect, 'guaranteed_gain')
|
||||
assert.equal(typeof medkit.effectValue, 'number')
|
||||
})
|
||||
|
||||
test('aucun attribut n\'est couvert par 2 objets différents (pas de conflit de cumul)', () => {
|
||||
const attrs = ITEMS.map(i => i.attr)
|
||||
assert.equal(new Set(attrs).size, attrs.length)
|
||||
})
|
||||
|
||||
describe('getItemById', () => {
|
||||
test('retourne l\'objet correspondant à un id existant', () => {
|
||||
const trebuchet = getItemById('trebuchet')
|
||||
assert.ok(trebuchet)
|
||||
assert.equal(trebuchet.name, 'Trébuchet')
|
||||
assert.equal(trebuchet.attr, 'war')
|
||||
})
|
||||
|
||||
test('retourne null pour un id inconnu', () => {
|
||||
assert.equal(getItemById('does-not-exist'), null)
|
||||
})
|
||||
|
||||
test('retourne null pour undefined/null', () => {
|
||||
assert.equal(getItemById(undefined), null)
|
||||
assert.equal(getItemById(null), null)
|
||||
})
|
||||
})
|
||||
})
|
||||