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() {} }