diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js index a579d7d..3e8f765 100644 --- a/client/src/game/objects/ItemBoardToken.js +++ b/client/src/game/objects/ItemBoardToken.js @@ -434,7 +434,14 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { } destroy(fromScene) { + // Annuler les DEUX minuteurs (pas seulement le hide) : si ce jeton est + // détruit (ex: reconstruction de la main d'événements après un + // game-state) pendant qu'un survol venait de démarrer, _showTimer + // resterait actif et appellerait _showExpanded() sur un objet déjà + // détruit une fois écoulé — pouvant faire réapparaître une carte + // "fantôme" après reconstruction, ou lever une erreur silencieuse. this._cancelHide() + this._cancelShow() super.destroy(fromScene) } } diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index a3aeff2..20967ec 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -68,10 +68,17 @@ export class GameScene extends Phaser.Scene { // compact toujours visible, carte détaillée en taille réelle au survol // (bien plus lisible que l'ancien affichage en grande carte réduite à // 0.8, dont le texte restait écrasé). - this.eventCards = [] // ItemBoardToken (kind='event') actuellement affichées (ma main) - this.selectedEventCard = null // ItemBoardToken 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 + this.eventCards = [] // ItemBoardToken (kind='event') actuellement affichées (ma main) + this._lastEventHandKey = null // clé combinée (main + carte verrouillée) : ne reconstruit qu'au vrai changement + // IMPORTANT : l'état "carte en cours de jeu" est piloté par un simple id + // (eventId), JAMAIS par une référence vers un objet Phaser — les jetons + // ItemBoardToken sont détruits/recréés à chaque rendu de la main + // (_renderEventHand), donc garder une référence directe finissait par + // pointer vers un objet déjà détruit, désynchronisant l'affichage de + // l'état réel (carte visuellement encore "jouable" après avoir déjà été + // envoyée au serveur). `selectedEventId` reste valide quel que soit le + // nombre de fois où les jetons sont reconstruits. + this.selectedEventId = null } create() { @@ -736,43 +743,49 @@ export class GameScene extends Phaser.Scene { } /** - * (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), en - * bas-droite du canvas. Même composant que les objets/bâtiments - * (ItemBoardToken, kind='event') : un petit jeton compact toujours - * visible, avec la carte détaillée (nom + description en taille réelle, - * bien plus lisible) affichée au survol et un bouton "JOUER" — l'ancien - * Le précédent composant dédié affichait une grande carte réduite à - * l'échelle 0.8, ce qui écrasait le texte au point de le rendre illisible. + * Reconstruit intégralement ma main de cartes événement (max + * EVENT_HAND_MAX=2), en bas-droite du canvas, à partir de l'état courant : + * `eventIds` (myEventHand reçu du serveur) et `this.selectedEventId`/ + * `this._targetingState` (verrouillage local en attente de confirmation + * serveur). C'est la SEULE fonction qui construit `this.eventCards` — pas + * de fonction séparée pour "juste changer l'affordance" comme auparavant, + * ce qui créait un second chemin de mutation pouvant désynchroniser + * l'affichage de l'état réel après un enchaînement rapide d'actions + * (carte visuellement encore "jouable" alors qu'elle avait déjà été + * envoyée au serveur, puis restée affichée si un game-state intermédiaire + * reconstruisait la main sans repropager le verrouillage correctement). + * + * Même composant que les objets/bâtiments (ItemBoardToken, kind='event') : + * un petit jeton compact toujours visible, avec la carte détaillée (nom + + * description en taille réelle, bien plus lisible) affichée au survol et + * un bouton "JOUER". */ - _buildEventHand(eventIds) { + _renderEventHand(eventIds) { this.eventCards.forEach(c => c.destroy()) this.eventCards = [] - this.selectedEventCard = null if (!eventIds || eventIds.length === 0) return + const locked = this.selectedEventId !== null || !!this._targetingState const { width, height } = this.scale const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length) eventIds.forEach((eventId, i) => { const eventData = getEventById(eventId) if (!eventData) return - // affordable=true : le bouton "JOUER" est activé tant qu'aucune autre - // carte événement n'a déjà été choisie ce tour (voir - // _refreshEventHandAffordability, appelé après chaque reconstruction - // et à chaque game-state pour refléter pendingEventPlayId). + // affordable=false si CETTE main contient déjà une carte verrouillée + // (une autre en cours d'envoi/ciblage) — recalculé à chaque rendu à + // partir de l'état courant, jamais mémorisé sur le jeton lui-même. // hAnchor='right' : la main est ancrée sur le bord DROIT du canvas // (voir eventHandLayout.js), la carte détaillée doit donc s'étendre // vers la GAUCHE au survol pour ne jamais déborder hors du canvas. // vAnchor='top' : la main est aussi ancrée en BAS du canvas — sans ce // paramètre, la carte détaillée s'étend vers le bas par défaut et // déborde largement sous le canvas, la rendant invisible/injoignable. - const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', true, 1, 'event', 'right', 'top') - token.on('play', (data) => this._onEventCardPlay(token, data)) + const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', !locked, 1, 'event', 'right', 'top') + token.on('play', (data) => this._onEventCardPlay(eventId, data)) this.eventCards.push(token) }) - - this._refreshEventHandAffordability() } _layoutEventHand() { @@ -784,35 +797,12 @@ export class GameScene extends Phaser.Scene { }) } - /** - * Le bouton "JOUER" de chaque carte événement en main doit être désactivé - * dès qu'une carte a déjà été choisie ce tour (une seule autorisée) ou - * qu'une séquence de ciblage est en cours — reconstruit les jetons pour - * refléter l'état courant (ItemBoardToken.affordable est fixé à la - * construction, pas réactif après coup). - */ - _refreshEventHandAffordability() { - const locked = !!this.selectedEventCard || !!this._targetingState - if (!locked) return // déjà construits avec affordable=true par défaut - - const eventIds = this.eventCards.map(c => c.itemData.id) - const { width, height } = this.scale - const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length) - - this.eventCards.forEach(c => c.destroy()) - this.eventCards = eventIds.map((eventId, i) => { - const eventData = getEventById(eventId) - const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', false, 1, 'event', 'right', 'top') - token.on('play', (data) => this._onEventCardPlay(token, data)) - return token - }) - } - /** * Clic sur le bouton "JOUER" d'une carte événement de ma main (une seule - * autorisée par tour). 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. + * autorisée par tour, identifiée par `eventId` — jamais une référence + * d'objet Phaser, voir this.selectedEventId). 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. * * Choix pragmatique : le ciblage d'Accord royal (spécifié "au moment de * piocher") est en réalité demandé ici, au moment de JOUER la carte, @@ -822,15 +812,15 @@ export class GameScene extends Phaser.Scene { * 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. */ - _onEventCardPlay(token, eventData) { - if (this.selectedEventCard) return // déjà une carte sélectionnée ce tour + _onEventCardPlay(eventId, eventData) { + if (this.selectedEventId !== null) return // déjà une carte sélectionnée ce tour if (this._targetingState) return // ciblage déjà en cours const needsTarget = eventData.needsTargetOnDraw || (eventData.needsTargetOnPlay && this._epidemicNeedsTarget()) if (!needsTarget) { - this._confirmPlayEvent(token, {}) + this._confirmPlayEvent(eventId, {}) return } @@ -867,10 +857,10 @@ export class GameScene extends Phaser.Scene { * avance jusqu'à ce que toutes les clés de `keys` soient renseignées, puis * émet 'play-event' avec les cibles collectées. */ - _startTargeting(token, keys, instructions) { - this._targetingState = { token, keys: [...keys], instructions, collected: {} } + _startTargeting(eventId, keys, instructions) { + this._targetingState = { eventId, keys: [...keys], instructions, collected: {} } this._highlightOpponentSlots(true) - this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes pendant le ciblage + this._renderEventHand(this.store.myEventHand) // grise le bouton JOUER des autres cartes pendant le ciblage this.phaseText.setText(instructions[keys[0]]) } @@ -913,7 +903,7 @@ export class GameScene extends Phaser.Scene { _onTargetSlotClicked(targetTeamIndex) { if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return - const { token, keys, instructions, collected } = this._targetingState + const { eventId, keys, instructions, collected } = this._targetingState const currentKey = keys[0] collected[currentKey] = targetTeamIndex keys.shift() @@ -922,25 +912,30 @@ export class GameScene extends Phaser.Scene { this._highlightOpponentSlots(false) const targets = this._targetingState.collected this._targetingState = null - this._confirmPlayEvent(token, targets) + this._confirmPlayEvent(eventId, targets) } else { this.phaseText.setText(instructions[keys[0]]) } } - /** Annule une séquence de ciblage en cours (ex: changement de phase serveur). */ + /** + * Annule une séquence de ciblage en cours (ex: changement de phase + * serveur). Reconstruit la main pour lever le verrouillage visuel si + * aucune carte n'a par ailleurs été confirmée entre-temps. + */ _cancelTargeting() { if (!this._targetingState) return this._highlightOpponentSlots(false) this._targetingState = null + if (this.store?.myEventHand) this._renderEventHand(this.store.myEventHand) } - _confirmPlayEvent(token, targets) { - this.selectedEventCard = token - this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes + _confirmPlayEvent(eventId, targets) { + this.selectedEventId = eventId + this._renderEventHand(this.store.myEventHand) // grise le bouton JOUER des autres cartes this.socket.emit('play-event', { roomId: this.roomId, - eventId: token.itemData.id, + eventId, targets }) } @@ -1162,24 +1157,27 @@ export class GameScene extends Phaser.Scene { 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 ?? []) + // Le serveur est la source de vérité pour "une carte a-t-elle déjà été + // choisie ce tour" (pendingEventPlayId, non-null tant que le tour + // n'est pas résolu). On synchronise TOUJOURS selectedEventId dessus + // avant de décider de reconstruire la main, pour éviter le bug où la + // main était reconstruite avec l'ancien état de verrouillage local + // (obsolète après une résolution de tour ou une reconnexion) : la + // carte réapparaissait alors "jouable" alors qu'elle avait déjà été + // envoyée au serveur, ou restait grisée après que le tour ait tourné. + this.selectedEventId = state.pendingEventPlayId ?? null + + // Reconstruire ma main d'événements si SA COMPOSITION a changé + // (pioche, ou carte jouée/défaussée après résolution) OU si l'état de + // verrouillage (carte en cours d'envoi/ciblage) a changé — les deux + // affectent le rendu (nombre de cartes, bouton JOUER actif/grisé). + const myEventHand = state.myEventHand ?? [] + const myEventHandKey = JSON.stringify([myEventHand, this.selectedEventId, !!this._targetingState]) if (this._lastEventHandKey !== myEventHandKey) { this._lastEventHandKey = myEventHandKey - this._buildEventHand(state.myEventHand ?? []) + this._renderEventHand(myEventHand) } - // Synchroniser l'état "carte déjà choisie ce tour" avec le serveur - // (utile après reconnexion, ou si l'action locale a précédé la - // confirmation serveur) pour maintenir le bouton JOUER des autres - // cartes grisé de façon cohérente. - if (state.pendingEventPlayId && !this.selectedEventCard) { - this.selectedEventCard = true - this._refreshEventHandAffordability() - } else if (!state.pendingEventPlayId && this.selectedEventCard) { - this.selectedEventCard = null - } - this._refreshEventDrawButtonState(me?.gauges, (state.myEventHand ?? []).length, !!state.hasPendingEventDraw) + this._refreshEventDrawButtonState(me?.gauges, myEventHand.length, !!state.hasPendingEventDraw) if (state.phase === 'selecting') { this._canPlay = true