Files
regnum/client/src/game/scenes/GameScene.js
T
Kevin L'Hereec 44d4eb7e96 fix: bugs critiques du système d'événements + stabilisation E2E
Trois bugs fonctionnels réels découverts et corrigés en stabilisant les
tests E2E des cartes Événement :

1. client/src/stores/game.js — le store Pinia n'exposait JAMAIS les champs
   liés aux bâtiments (myBuildings, pendingBuyBuildingId, availableBuildings)
   ni aux événements (myEventHand, eventDeckSize, hasPendingEventDraw,
   pendingEventPlayId) : updateState() ne les lisait pas depuis le
   'game-state' serveur. Conséquence concrète : GameScene._epidemicNeedsTarget()
   lisait this.store.myBuildings (toujours vide) pour décider si la
   variante Église d'Épidémie s'applique — le ciblage de la variante Trousse
   ne fonctionnait donc jamais correctement dès qu'un joueur possédait
   aussi le Château. Corrigé + 4 nouveaux tests unitaires Vitest.

2. client/src/game/scenes/GameScene.js — GameScene._onEventCardSelected
   envoyait encore les clés historiques gainTargetSocketId/
   loseTargetSocketId/targetSocketId au serveur, alors que Room.js avait été
   migré vers gainTargetTeamIndex/loseTargetTeamIndex/targetTeamIndex
   (socketId n'est jamais exposé au client). Conséquence : le ciblage
   d'Accord royal et de la variante Trousse d'Épidémie était intégralement
   cassé en jeu réel — les clés ne correspondant à rien côté serveur, aucune
   cible n'était jamais appliquée, silencieusement.

3. e2e/tests/helpers.js — drawEventUntil abandonnait immédiatement dès que
   la main d'événements atteignait 2 cartes sans contenir l'id recherché,
   rendant les tests E2E d'événements extrêmement flaky (avec seulement 6
   types d'événements et une main de 2, l'échec dès les 2 premières pioches
   est fréquent, pas rare). Corrigé : joue désormais une carte pour libérer
   un slot et continue à piocher jusqu'à maxDraws (20).

Stabilisation supplémentaire :
- Toutes les fonctions d'introspection Phaser dans helpers.js sécurisées
  (optional chaining) contre une scène pas encore initialisée après un
  changement de tour/navigation sous charge (plusieurs pages Playwright
  simultanées).
- Timeouts des tests events.spec.js augmentés (150-240s) pour laisser le
  temps aux boucles d'accumulation de jauge + pioche aléatoire de converger.
- playwright.config.js : retries=1 même en local, pour absorber la
  variance résiduelle des tests basés sur un tirage aléatoire, tout en
  gardant une détection fiable des vraies régressions (qui échoueraient de
  façon reproductible sur les deux tentatives).
- events.spec.js : test 'Accord royal' fournit désormais les 2 cibles
  requises par le ciblage séquentiel (gain + perte) au lieu d'une seule,
  ce qui laissait la séquence de ciblage bloquée sans jamais émettre
  'play-event'.

Résultat : 166 tests unitaires serveur + 28 tests unitaires client
toujours au vert ; suite events.spec.js (9 tests E2E) passe intégralement
(certains via retry probabiliste documenté, comportement attendu vu la
pioche aléatoire dans le deck partagé).
2026-08-24 13:24:37 +02:00

1304 lines
51 KiB
JavaScript

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 { EventCardObject } from '../objects/EventCardObject'
import { ITEMS, getItemById } from '../items'
import { BUILDINGS, getBuildingById } from '../buildings'
import { EVENTS, getEventById, DRAW_COST } from '../events'
// 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,
1: 0x1155cc,
2: 0x1a7a2e,
3: 0xb8860b
}
// Couleurs attributs (hex, pour les Graphics)
const ATTR_COLORS = {
food: 0xf0c030,
war: 0xdd2222,
health: 0x22aa44,
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() {
super('GameScene')
this.handCards = []
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 objets, à gauche) 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
// ── Bâtiments sur le plateau (même principe, boutique à DROITE) ────────────
this.buildingTokens = [] // ItemBoardToken (boutique bâtiments, à droite)
this.myBuildingTokens = [] // ItemBoardToken des bâtiments que JE possède, à côté de ma main
this.opponentBuildingTokens = [] // ItemBoardToken des bâtiments possédés par les autres joueurs
// ── Cartes Événement (seconde main, max 2, sous ma main de personnages) ────
this.eventCards = [] // EventCardObject actuellement affichées (ma main d'événements)
this.selectedEventCard = null // EventCardObject choisie pour être jouée ce tour (avant confirmation)
this._lastEventHandKey = null // pour ne reconstruire qu'au vrai changement de main d'événements
this._pendingEventTargets = {} // cibles choisies pour l'événement en cours de sélection/ciblage
}
create() {
this.socket = this.registry.get('socket')
this.store = this.registry.get('store')
this.roomId = this.registry.get('roomId')
this._buildTable()
this._buildPlayButton()
this._buildItemTokens()
this._buildBuildingTokens()
this._buildEventDrawButton()
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
}
this._cancelTargeting()
}
/**
* 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.bgRect = this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500)
// Zone de jeu centrale
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
this.turnText = this.add.text(width / 2, 18, 'Tour 1 / 12', {
fontSize: '16px',
fontFamily: 'Georgia',
color: '#d4a732'
}).setOrigin(0.5)
// Phase courante
this.phaseText = this.add.text(width / 2, 40, 'Sélectionnez une carte', {
fontSize: '13px',
fontFamily: 'Georgia',
color: '#a08060'
}).setOrigin(0.5)
// Les slots sont créés dynamiquement dans _buildSlots(n) une fois le nb de joueurs connu
this.playSlots = []
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()
// Colonnes de jetons objets (gauche) et bâtiments (droite)
this._layoutItemTokens()
this._layoutBuildingTokens()
// Bouton de pioche d'événement + ma main d'événements
this._layoutEventDrawButton()
this._layoutEventHand()
// Main du joueur (repositionnement simple, sans reconstruction)
this._repositionHand()
}
_buildSlots(playerCount) {
const { width, height } = this.scale
// Détruire les anciens slots (et les mini-cartes objets/bâtiments des adversaires)
this.slotObjects.forEach(o => o.destroy())
this.slotObjects = []
this.playSlots = []
this.opponentItemTokens.forEach(t => t.destroy())
this.opponentItemTokens = []
this.opponentBuildingTokens.forEach(t => t.destroy())
this.opponentBuildingTokens = []
const slotY = height / 2 - 60
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(-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,
player?.name ?? `J${i + 1}`, {
fontSize: '11px',
fontFamily: 'Georgia',
color: '#4a3020'
}).setOrigin(0.5)
this.slotObjects.push(label)
// Mini-cartes objets/bâtiments "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/bâtiments 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)
})
// Bâtiments possédés, sur une seconde rangée sous les objets
const ownedBuildings = player?.buildings ?? []
const startBuildingX = x - (ownedBuildings.length - 1) * tokenGap / 2
ownedBuildings.forEach((buildingId, k) => {
const building = getBuildingById(buildingId)
if (!building) return
const token = new ItemBoardToken(this, startBuildingX + k * tokenGap, slotY + 152, building, 'owned', false, 0.55, 'building')
this.opponentBuildingTokens.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' },
{ attr: 'health', label: '🟢 Santé — bas gauche' },
{ attr: 'diplo', label: '🔵 Diplomatie — bas droite' }
]
entries.forEach((e, i) => {
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 } = this.scale
const btnY = this._playButtonY()
// Fond du bouton (rectangle arrondi via Graphics)
this.playBtnBg = this.add.graphics()
this._drawBtnBg(false)
// Texte du bouton
this.playBtn = this.add.text(width / 2, btnY, '✔ CONFIRMER MA CARTE', {
fontSize: '17px',
fontFamily: 'Georgia',
fontStyle: 'bold',
color: '#a08060',
padding: { x: 28, y: 10 }
})
.setOrigin(0.5)
.setAlpha(1)
.setDepth(100)
.setInteractive({ cursor: 'pointer' })
.on('pointerdown', this._playSelectedCard, this)
.on('pointerover', () => { if (this._canPlay && this.selectedCard) this.playBtn.setScale(1.05) })
.on('pointerout', () => { this.playBtn.setScale(1) })
// Compteur de joueurs prêts (ex: "1 / 2 prêts")
this.readyText = this.add.text(width / 2, btnY + 28, '', {
fontSize: '12px',
fontFamily: 'Georgia',
color: '#a08060'
}).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 } = this.scale
const btnY = this._playButtonY()
const btnW = 280
const btnH = 42
this.playBtnBg.clear()
if (active) {
this.playBtnBg.fillStyle(0xd4a732, 1)
} else {
this.playBtnBg.fillStyle(0x4a3010, 0.5)
}
this.playBtnBg.fillRoundedRect(width / 2 - btnW / 2, btnY - btnH / 2, btnW, btnH, 8)
if (active) {
this.playBtnBg.lineStyle(2, 0xffd700)
this.playBtnBg.strokeRoundedRect(width / 2 - btnW / 2, btnY - btnH / 2, btnW, btnH, 8)
}
this.playBtnBg.setDepth(99)
}
_activateBtn() {
this._drawBtnBg(true)
// Texte sombre sur fond doré
this.playBtn.setColor('#1a0a00').setAlpha(1)
if (this._pulseTween) this._pulseTween.stop()
this._pulseTween = this.tweens.add({
targets: this.playBtnBg,
alpha: 0.85,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
})
}
_deactivateBtn() {
this._drawBtnBg(false)
// Texte clair sur fond sombre pour rester lisible
this.playBtn.setColor('#6a5040').setAlpha(1).setScale(1)
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)
})
}
// ─── Bâtiments sur le plateau (miroir des objets, boutique à DROITE) ───────
_buildingTokensX() {
return this.scale.width - 70
}
_buildingTokensStartY() {
return 130
}
_buildingTokensSpacing() {
return ICON_SIZE + 60
}
/**
* (Re)construit la colonne à DROITE avec uniquement les bâtiments encore
* disponibles à l'achat — même logique que _buildItemTokens (disparition
* complète une fois acheté, les restants remontent pour combler le vide).
*/
_buildBuildingTokens() {
this.buildingTokens.forEach(t => t.destroy())
this.buildingTokens = []
const x = this._buildingTokensX()
const startY = this._buildingTokensStartY()
const spacing = this._buildingTokensSpacing()
const state = this._shopState ?? { myBuildings: [], availableBuildings: BUILDINGS.map(b => b.id) }
const stillAvailable = BUILDINGS.filter(b =>
state.availableBuildings.includes(b.id) && !state.myBuildings.includes(b.id)
)
if (stillAvailable.length > 0) {
if (!this.buildingTokensLabel) {
this.buildingTokensLabel = this.add.text(x, startY - ICON_SIZE / 2 - 16, 'BÂTIMENTS\n(survoler)', {
fontSize: '9px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#6a5040', align: 'center'
}).setOrigin(0.5, 1)
} else {
this.buildingTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
this.buildingTokensLabel.setVisible(true)
}
} else if (this.buildingTokensLabel) {
this.buildingTokensLabel.setVisible(false)
}
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((building, i) => {
const affordable = Object.entries(building.cost)
.every(([attr, cost]) => (myGauges[attr] ?? 0) >= cost)
// hAnchor='right' : cette boutique est sur le bord DROIT du canvas,
// la carte détaillée doit donc s'étendre vers la GAUCHE au survol
// pour ne jamais déborder hors du canvas (sinon son bouton ACHETER
// devient injoignable au clic, cf. bug corrigé).
const token = new ItemBoardToken(this, x, startY + i * spacing, building, 'available', affordable, 1, 'building', 'right')
token.on('buy', (data) => this._onBuyBuilding(data))
this.buildingTokens.push(token)
})
}
/** Recale la colonne de jetons bâtiments verticalement sur le bord droit du canvas. */
_layoutBuildingTokens() {
const x = this._buildingTokensX()
const startY = this._buildingTokensStartY()
const spacing = this._buildingTokensSpacing()
if (this.buildingTokensLabel) this.buildingTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
this.buildingTokens.forEach((token, i) => token.setPosition(x, startY + i * spacing))
}
_onBuyBuilding(buildingData) {
this.socket.emit('buy-building', { roomId: this.roomId, buildingId: buildingData.id })
}
/**
* (Re)construit les mini-cartes des bâtiments que JE possède, affichées
* juste à GAUCHE de ma main (symétrique de mes objets à droite) — posés
* devant moi sur la table, de l'autre côté de mes cartes personnage.
*/
_buildMyBuildingTokens() {
this.myBuildingTokens.forEach(t => t.destroy())
this.myBuildingTokens = []
const myBuildingIds = this._shopState?.myBuildings ?? []
if (myBuildingIds.length === 0) return
myBuildingIds.forEach((buildingId) => {
const buildingData = BUILDINGS.find(b => b.id === buildingId)
if (!buildingData) return
const token = new ItemBoardToken(this, 0, 0, buildingData, 'owned', false, 0.7, 'building')
this.myBuildingTokens.push(token)
})
this._layoutMyBuildingTokens()
}
/** Positionne mes mini-cartes bâtiments juste à gauche de ma main de cartes. */
_layoutMyBuildingTokens() {
if (this.myBuildingTokens.length === 0) return
const { handY, spacing, startX } = this._handGeometry(this.handCards.length)
const handLeftEdge = startX - CARD_W / 2
const gap = 18
const tokenGap = ICON_SIZE * 0.7 + 10
// Placés en partant du bord gauche de la main vers la gauche (ordre
// inversé pour que le premier bâtiment acheté reste le plus proche
// de la main, symétrique à mes objets qui partent du bord droit).
this.myBuildingTokens.forEach((token, i) => {
token.setPosition(handLeftEdge - gap - ICON_SIZE * 0.7 / 2 - i * tokenGap, handY)
})
}
// ─── Cartes Événement (pioche + seconde main + ciblage) ────────────────────
/** Position du bouton de pioche d'événement : à droite du bouton de confirmation, même Y. */
_eventDrawButtonPos() {
const { width } = this.scale
return { x: width / 2 + 210, y: this._playButtonY() }
}
_buildEventDrawButton() {
const { x, y } = this._eventDrawButtonPos()
this.eventDrawBtnBg = this.add.graphics().setDepth(99)
this.eventDrawBtn = this.add.text(x, y, '🎴 Piocher un Événement', {
fontSize: '12px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#d4a732', padding: { x: 10, y: 6 }
})
.setOrigin(0.5)
.setDepth(100)
.setInteractive({ cursor: 'pointer' })
.on('pointerdown', () => this._onBuyEventDraw())
.on('pointerover', () => this.eventDrawBtn.setScale(1.05))
.on('pointerout', () => this.eventDrawBtn.setScale(1))
this._drawEventDrawBtnBg()
}
_drawEventDrawBtnBg() {
const { x, y } = this._eventDrawButtonPos()
const w = 190, h = 30
this.eventDrawBtnBg.clear()
this.eventDrawBtnBg.fillStyle(0x2a1a0a, 0.9)
this.eventDrawBtnBg.fillRoundedRect(x - w / 2, y - h / 2, w, h, 6)
this.eventDrawBtnBg.lineStyle(1.5, 0xd4a73280)
this.eventDrawBtnBg.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 6)
}
_layoutEventDrawButton() {
const { x, y } = this._eventDrawButtonPos()
if (this.eventDrawBtn) this.eventDrawBtn.setPosition(x, y)
this._drawEventDrawBtnBg()
}
/**
* Met à jour l'apparence du bouton de pioche selon que le tirage est
* réellement possible : jauges suffisantes (DRAW_COST = 1 point dans
* chacune des 4), main d'événements pas déjà pleine, et pas de pioche déjà
* tentée ce tour. Grisé sinon, cohérent avec le pattern déjà utilisé pour
* les boutons ACHETER des objets/bâtiments.
*/
_refreshEventDrawButtonState(myGauges, myEventHandSize, hasPendingDraw) {
if (!this.eventDrawBtn) return
const affordable = Object.entries(DRAW_COST).every(([attr, cost]) => (myGauges?.[attr] ?? 0) >= cost)
const handFull = myEventHandSize >= 2
const canDraw = affordable && !handFull && !hasPendingDraw
this.eventDrawBtn.setColor(canDraw ? '#d4a732' : '#6a5040')
this.eventDrawBtn.disableInteractive()
if (canDraw) this.eventDrawBtn.setInteractive({ cursor: 'pointer' })
let label = '🎴 Piocher un Événement'
if (handFull) label = '🎴 Main pleine (2/2)'
else if (hasPendingDraw) label = '🎴 Pioche faite ce tour'
else if (!affordable) label = '🎴 Piocher (1 pt/jauge)'
this.eventDrawBtn.setText(label)
}
_onBuyEventDraw() {
this.socket.emit('buy-event-draw', { roomId: this.roomId })
}
/** Position/espacement de ma main de cartes événement (max 2), au-dessus de mon bouton de pioche. */
_eventHandGeometry() {
const { x } = this._eventDrawButtonPos()
const y = this._playButtonY() - 100
return { x, y, spacing: 90 }
}
/**
* (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), à
* l'échelle réduite pour rester compacte au-dessus du bouton de pioche.
*/
_buildEventHand(eventIds) {
this.eventCards.forEach(c => c.destroy())
this.eventCards = []
this.selectedEventCard = null
if (!eventIds || eventIds.length === 0) return
const { x, y, spacing } = this._eventHandGeometry()
const startX = x - (eventIds.length - 1) * spacing / 2
eventIds.forEach((eventId, i) => {
const eventData = getEventById(eventId)
if (!eventData) return
const card = new EventCardObject(this, startX + i * spacing, y, eventData)
card.setScale(0.55)
card.on('select', this._onEventCardSelected, this)
this.eventCards.push(card)
})
}
_layoutEventHand() {
if (this.eventCards.length === 0) return
const { x, y, spacing } = this._eventHandGeometry()
const startX = x - (this.eventCards.length - 1) * spacing / 2
this.eventCards.forEach((card, i) => {
card.setPosition(startX + i * spacing, y)
card._baseY = y
})
}
/**
* Sélection d'une carte événement pour la jouer ce tour (une seule
* autorisée). Si l'événement nécessite un ciblage, ouvre la séquence de
* ciblage (clic sur les slots adversaires) avant d'émettre 'play-event' —
* sinon l'émission est immédiate, sans bouton de confirmation séparé
* (même pattern que l'achat d'objet/bâtiment : action -> résolution différée
* côté serveur).
*
* Choix pragmatique : le ciblage d'Accord royal (spécifié "au moment de
* piocher") est en réalité demandé ici, au moment de la SÉLECTION pour
* jouer, plutôt qu'immédiatement après le tirage. Cela évite un bug réel
* de correspondance d'index côté client si le joueur possède 2 exemplaires
* du même événement en main (le tableau `myEventHand` ne porte aucun
* identifiant d'instance, seulement des ids répétables) tout en envoyant
* les cibles au serveur exactement au même endroit du flux qu'attendu.
*/
_onEventCardSelected(card) {
if (this.selectedEventCard) return // déjà une carte sélectionnée ce tour
if (this._targetingState) return // ciblage déjà en cours
const event = card.eventData
const needsTarget = event.needsTargetOnDraw ||
(event.needsTargetOnPlay && this._epidemicNeedsTarget())
if (!needsTarget) {
this._confirmPlayEvent(card, {})
return
}
if (event.id === 'royal_accord') {
// IMPORTANT : ces clés doivent correspondre exactement au contrat
// attendu par le serveur (Room.playEventCard / _resolvePendingEventPlays,
// server/src/game/Room.js) — le ciblage transite par teamIndex
// (identifiant public), jamais par socketId (donnée serveur interne
// jamais exposée au client).
this._startTargeting(card, ['gainTargetTeamIndex', 'loseTargetTeamIndex'], {
gainTargetTeamIndex: 'Choisissez un joueur qui gagnera 1 Diplomatie',
loseTargetTeamIndex: 'Choisissez un joueur qui perdra 1 Diplomatie'
})
} else if (event.id === 'epidemic') {
this._startTargeting(card, ['targetTeamIndex'], {
targetTeamIndex: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)'
})
}
}
/** Vrai si jouer Épidémie maintenant déclencherait la variante Trousse (nécessite un ciblage). */
_epidemicNeedsTarget() {
const myItems = this.store.myItems ?? []
const myBuildings = this.store.myBuildings ?? []
// La variante Église (si possédée) prend le pas et ne nécessite aucun
// ciblage (tous les adversaires affectés automatiquement) — cohérent
// avec la priorité Église > Trousse déjà appliquée côté serveur.
return myItems.includes('medkit') && !myBuildings.includes('church')
}
/**
* Démarre une séquence de ciblage : met en évidence les slots adversaires
* cliquables, affiche l'instruction correspondant à l'étape courante, et
* avance jusqu'à ce que toutes les clés de `keys` soient renseignées, puis
* émet 'play-event' avec les cibles collectées.
*/
_startTargeting(card, keys, instructions) {
this._targetingState = { card, keys: [...keys], instructions, collected: {} }
this._highlightOpponentSlots(true)
this.phaseText.setText(instructions[keys[0]])
}
_highlightOpponentSlots(enable) {
this.playSlots.forEach((slot, i) => {
const player = this.store.players[i]
const isMe = player?.teamIndex === this.store.myTeamIndex
if (isMe || !player) return
if (enable) {
const hl = this.add.graphics().setDepth(60)
hl.lineStyle(3, 0xffd700, 1)
hl.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10)
hl.x = slot.x
hl.y = slot.y
hl.setInteractive(
new Phaser.Geom.Rectangle(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8),
Phaser.Geom.Rectangle.Contains
)
hl.input.cursor = 'pointer'
hl.on('pointerdown', () => this._onTargetSlotClicked(player.teamIndex))
this._targetHighlights = this._targetHighlights ?? []
this._targetHighlights.push(hl)
}
})
if (!enable) {
(this._targetHighlights ?? []).forEach(hl => hl.destroy())
this._targetHighlights = []
}
}
/**
* Le ciblage transite par `teamIndex` (identifiant public, stable et
* unique par joueur pour toute la partie) plutôt que par `socketId`
* (jamais exposé au client — donnée serveur interne, voir
* Room.getPlayersInfo). Le serveur résout targetTeamIndex -> le vrai
* joueur ciblé lui-même (voir Room.playEventCard / _resolvePendingEventPlays).
*/
_onTargetSlotClicked(targetTeamIndex) {
if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return
const { card, keys, instructions, collected } = this._targetingState
const currentKey = keys[0]
collected[currentKey] = targetTeamIndex
keys.shift()
if (keys.length === 0) {
this._highlightOpponentSlots(false)
const targets = this._targetingState.collected
this._targetingState = null
this._confirmPlayEvent(card, targets)
} else {
this.phaseText.setText(instructions[keys[0]])
}
}
/** Annule une séquence de ciblage en cours (ex: changement de phase serveur). */
_cancelTargeting() {
if (!this._targetingState) return
this._highlightOpponentSlots(false)
this._targetingState = null
}
_confirmPlayEvent(card, targets) {
card.select()
this.selectedEventCard = card
this.socket.emit('play-event', {
roomId: this.roomId,
eventId: card.eventData.id,
targets
})
}
// ─── Main du joueur ──────────────────────────────────────────────────────────
/**
* 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, 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()
this._layoutMyBuildingTokens()
}
_onCardSelected(card) {
if (!this._canPlay) return
// Désélectionner l'ancienne
if (this.selectedCard && this.selectedCard !== card) {
this.selectedCard.deselect()
}
this.selectedCard = card
this.store.selectCard(card.cardData)
this._activateBtn()
}
_playSelectedCard() {
if (!this.selectedCard || !this._canPlay) return
// Bloquer immédiatement pour éviter le double clic
this._canPlay = false
this._deactivateBtn()
this.socket.emit('play-card', {
roomId: this.roomId,
cardId: this.selectedCard.cardData.id
})
this.selectedCard.setAlpha(0.4)
this.selectedCard = null
this.store.clearSelection()
this.phaseText.setText('En attente des autres joueurs...')
}
// ─── Résolution visuelle ─────────────────────────────────────────────────────
/**
* Affiche les cartes jouées et les gains obtenus (soustraction voisin gauche)
* @param {Array} playedCards - cartes jouées dans l'ordre des joueurs
* @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', 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) => {
const slot = this.playSlots[i]
if (!slot) return
const teamColor = TEAM_COLORS[entry.teamIndex] ?? 0x888888
const gain = gains[i] ?? {}
const textureKey = `card-${entry.cardId}`
const hasTexture = entry.cardId && this.textures.exists(textureKey)
// ── 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)
}
// ── 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 - rCARD_H / 2 - 14, entry.playerName, {
fontSize: '11px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
}).setOrigin(0.5)
)
// ── Nom de la carte, simple texte en bas ────────────────────
this.resolveObjects.push(
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)
)
// ── 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
this.resolveObjects.push(
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)
)
if (gainVal > 0) {
this.resolveObjects.push(
this.add.text(cx, cy - 16, `+${gainVal}`, {
fontSize: '11px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#7CFC9A', stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5)
)
}
})
})
}
// ─── Socket listeners ────────────────────────────────────────────────────────
_setupSocketListeners() {
this.socket.on('game-state', (state) => {
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 ?? [],
myBuildings: state.myBuildings ?? [],
pendingBuyItemId: state.pendingBuyItemId ?? null,
pendingBuyBuildingId: state.pendingBuyBuildingId ?? null,
availableItems: state.availableItems ?? [],
availableBuildings: state.availableBuildings ?? [],
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
const buildingShopChanged = !this._shopState ||
JSON.stringify(this._shopState.myBuildings) !== JSON.stringify(newShopState.myBuildings) ||
JSON.stringify(this._shopState.availableBuildings) !== JSON.stringify(newShopState.availableBuildings) ||
this._lastMyGaugesKey !== myGaugesKey
this._shopState = newShopState
this._lastMyGaugesKey = myGaugesKey
if (shopChanged) this._buildItemTokens()
if (buildingShopChanged) this._buildBuildingTokens()
// Reconstruire mes mini-cartes objets/bâtiments uniquement si MA liste
// possédée 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()
}
const myBuildingsKey = JSON.stringify(newShopState.myBuildings)
if (this._lastMyBuildingsKey !== myBuildingsKey) {
this._lastMyBuildingsKey = myBuildingsKey
this._buildMyBuildingTokens()
}
// Reconstruire ma main d'événements uniquement si elle a réellement
// changé (pioche, ou carte jouée/défaussée après résolution).
const myEventHandKey = JSON.stringify(state.myEventHand ?? [])
if (this._lastEventHandKey !== myEventHandKey) {
this._lastEventHandKey = myEventHandKey
this._buildEventHand(state.myEventHand ?? [])
}
this._refreshEventDrawButtonState(me?.gauges, (state.myEventHand ?? []).length, !!state.hasPendingEventDraw)
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é (premier
// tour), OU si la liste d'objets/bâtiments possédés par un adversaire
// a changé (leurs mini-cartes, affichées sous leur slot, doivent
// alors être mises à jour).
const opponentItemsKey = JSON.stringify(
state.players.map(p => [p.items ?? [], p.buildings ?? []])
)
if (this.playSlots.length !== state.players.length || this._lastOpponentItemsKey !== opponentItemsKey) {
this._lastOpponentItemsKey = opponentItemsKey
this._buildSlots(state.players.length)
}
// 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)
}
}
}
if (state.phase === 'waiting') {
this._canPlay = false
this._deactivateBtn()
this.phaseText.setText('Carte confirmée — en attente des autres...')
}
if (state.phase === 'resolve' || state.phase === 'end') {
this._canPlay = false
this._deactivateBtn()
// Sécurité : une séquence de ciblage en cours n'a plus de sens une
// fois le tour résolu (ex: l'utilisateur a laissé un ciblage
// inachevé et le tour a été résolu par les autres joueurs entre-temps).
this._cancelTargeting()
}
})
this.socket.on('players-ready', ({ ready, total }) => {
if (this.readyText) {
this.readyText.setText(`${ready} / ${total} joueurs prêts`)
}
})
this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings, eventResults }) => {
this.phaseText.setText('Résolution du tour...')
this.readyText.setText('')
this._showPlayedCards(playedCards, gains)
const boughtLabels = [
...(boughtItems ?? []).map(b => `${b.playerName} → ${b.itemName}`),
...(boughtBuildings ?? []).map(b => `${b.playerName} → ${b.buildingName}`)
]
const eventLabels = (eventResults ?? []).map(e => e.summary)
const allLabels = [...boughtLabels, ...eventLabels]
if (allLabels.length > 0) {
this.readyText.setText(`🛒🎴 ${allLabels.join(' | ')}`)
}
})
// 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 ────────────────────────────────────────────────────────
/**
* Affiche d'abord les cartes déjà en main, puis fait tomber la nouvelle
* (toujours la dernière du tableau, ajoutée par nextTurn côté serveur)
*/
_animateDraw(cards) {
// 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]
this._buildHand(existing)
if (!newCard) return
// Calculer la position finale de la nouvelle carte dans la main complète
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)
card.setDepth(50)
card.on('select', this._onCardSelected, this)
card.setAlpha(0)
// Animation : fade + slide vers la position finale
this.tweens.add({
targets: card,
y: handY,
alpha: 1,
duration: 400,
ease: 'Back.Out',
delay: 100,
onComplete: () => {
card._baseY = handY
card.setDepth(0)
this.handCards.push(card)
// Recalculer le spacing de toutes les cartes maintenant qu'on en a une de plus
this._repositionHand()
}
})
}
/** Repositionne toutes les cartes en main avec le bon espacement */
_repositionHand() {
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
card._baseY = handY
this.tweens.add({
targets: card,
x: targetX,
y: handY,
duration: 250,
ease: 'Power2'
})
})
this._layoutMyItemTokens()
this._layoutMyBuildingTokens()
}
// ─── Utilitaires ─────────────────────────────────────────────────────────────
_darken(hex, factor) {
const r = Math.floor(((hex >> 16) & 0xff) * factor)
const g = Math.floor(((hex >> 8) & 0xff) * factor)
const b = Math.floor(( hex & 0xff) * factor)
return (r << 16) | (g << 8) | b
}
update() {}
}