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é).
1304 lines
51 KiB
JavaScript
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() {}
|
|
}
|