diff --git a/client/src/game/objects/EventCardObject.js b/client/src/game/objects/EventCardObject.js deleted file mode 100644 index b7b972e..0000000 --- a/client/src/game/objects/EventCardObject.js +++ /dev/null @@ -1,136 +0,0 @@ -import Phaser from 'phaser' -import { CARD_W, CARD_H, RADIUS } from './cardLayout' - -/** - * Carte Événement dans la "seconde main" du joueur (max 2 cartes, piochées - * séparément des cartes personnage). Reprend le style épuré de CardObject - * (portrait plein cadre, nom en bas) avec un liseré doré distinctif (au lieu - * de la couleur d'équipe, non pertinente ici) et un badge rouge "!" pour les - * événements à effet négatif (Épidémie), afin que le joueur identifie vite - * les risques avant de choisir de la jouer. - * - * Contrairement à CardObject, la sélection ici signifie "je choisis de jouer - * CETTE carte événement ce tour" (au maximum une par tour) — gérée par - * GameScene, qui peut aussi déclencher une UI de ciblage supplémentaire - * avant de confirmer réellement l'émission au serveur. - */ -export class EventCardObject extends Phaser.GameObjects.Container { - constructor(scene, x, y, eventData) { - super(scene, x, y) - this.eventData = eventData - this._selected = false - this._baseY = y - this._build() - scene.add.existing(this) - } - - _build() { - const textureKey = `event-${this.eventData.id}` - const hasTexture = this.scene.textures.exists(textureKey) - - // ── 1. Ombre légère ───────────────────────────────────────────────────── - const shadow = this.scene.add.graphics() - shadow.fillStyle(0x000000, 0.4) - shadow.fillRoundedRect(-CARD_W / 2 + 3, -CARD_H / 2 + 3, CARD_W, CARD_H, RADIUS) - this.add(shadow) - - // ── 2. Image portrait plein cadre ───────────────────────────────────────── - if (hasTexture) { - const img = this.scene.add.image(0, 0, textureKey) - this.add(img) - } else { - const bg = this.scene.add.graphics() - bg.fillStyle(0x2a1810, 1) - bg.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS) - this.add(bg) - const initial = this.scene.add.text(0, -10, this.eventData.name[0], { - fontSize: '70px', fontFamily: 'Georgia', color: '#ffffff' - }).setOrigin(0.5).setAlpha(0.15) - this.add(initial) - } - - // ── 3. Liseré doré (distinct de la couleur d'équipe des personnages) ────── - const border = this.scene.add.graphics() - border.lineStyle(1.5, 0xd4a732, 0.9) - border.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS) - this.add(border) - - // ── 4. Bandeau + nom en bas ────────────────────────────────────────────── - // Un fond semi-opaque derrière le texte (au lieu du seul contour) garde - // le nom lisible quel que soit le portrait en arrière-plan, cohérent - // avec l'affichage renforcé demandé pour cette carte (échelle réduite - // en main : voir EVENT_CARD_SCALE dans eventHandLayout.js). - const nameBandH = 34 - const nameBand = this.scene.add.graphics() - nameBand.fillStyle(0x000000, 0.55) - nameBand.fillRect(-CARD_W / 2 + 3, CARD_H / 2 - nameBandH, CARD_W - 6, nameBandH - 3) - this.add(nameBand) - - this.add(this.scene.add.text( - 0, CARD_H / 2 - nameBandH / 2 - 1, - this.eventData.name, { - fontSize: '15px', - fontFamily: 'Georgia, serif', - fontStyle: 'bold', - color: '#f5e6c8', - stroke: '#000000', - strokeThickness: 3, - align: 'center', - lineSpacing: 2, - wordWrap: { width: CARD_W - 10 } - } - ).setOrigin(0.5, 0.5)) - - // ── 5. Badge "effet négatif" (coin haut-droit) ──────────────────────────── - if (this.eventData.negative) { - const bx = CARD_W / 2 - 16, by = -CARD_H / 2 + 16 - const ring = this.scene.add.graphics() - ring.fillStyle(0x1a0a00, 0.95) - ring.fillCircle(bx, by, 12) - this.add(ring) - const disk = this.scene.add.graphics() - disk.fillStyle(0xcc2222, 1) - disk.fillCircle(bx, by, 10) - this.add(disk) - this.add(this.scene.add.text(bx, by, '!', { - fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' - }).setOrigin(0.5)) - } - - // ── 6. Interactivité ────────────────────────────────────────────────────── - this.setSize(CARD_W, CARD_H) - this.setInteractive({ cursor: 'pointer' }) - this.on('pointerover', this._onHover, this) - this.on('pointerout', this._onOut, this) - this.on('pointerdown', this._onDown, this) - } - - _onHover() { - if (this._selected) return - this.scene.tweens.add({ targets: this, y: this._baseY - 18, duration: 120, ease: 'Power2' }) - this.setDepth(10) - } - - _onOut() { - if (this._selected) return - this.scene.tweens.add({ targets: this, y: this._baseY, duration: 120, ease: 'Power2' }) - this.setDepth(0) - } - - _onDown() { - if (this._selected) { this.deselect() } - else { this.select(); this.emit('select', this) } - } - - select() { - this._selected = true - this.setDepth(20) - this.scene.tweens.add({ targets: this, y: this._baseY - 28, duration: 150, ease: 'Back.Out' }) - } - - deselect() { - this._selected = false - this.setDepth(0) - this.scene.tweens.add({ targets: this, y: this._baseY, duration: 150, ease: 'Power2' }) - } -} diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js index b82c1c5..64a17d2 100644 --- a/client/src/game/objects/ItemBoardToken.js +++ b/client/src/game/objects/ItemBoardToken.js @@ -1,4 +1,5 @@ import Phaser from 'phaser' +import { ICON_SIZE } from './tokenLayout' const ATTR_COLORS = { food: 0xf0c030, @@ -14,9 +15,12 @@ const ATTR_LABELS = { diplo: 'Diplomatie' } -// Taille du jeton compact affiché en permanence sur le plateau (avant mise -// à l'échelle éventuelle via displayScale — voir constructeur). -export const ICON_SIZE = 46 +// Re-export pour compatibilité : ICON_SIZE est désormais défini dans +// tokenLayout.js (module pur, sans dépendance Phaser) afin que les modules +// de calcul de géométrie (ex: eventHandLayout.js) puissent l'importer sans +// jamais charger Phaser — nécessaire pour rester testables en Vitest/jsdom, +// qui échoue à l'import de Phaser (canvas natif requis, absent de jsdom). +export { ICON_SIZE } // Taille de la carte détaillée affichée au survol. Volontairement grande et // avec des polices généreuses pour que la description de l'effet reste @@ -49,29 +53,38 @@ const HOVER_SHOW_DELAY = 80 * dessus le plateau, avec un bouton "ACHETER" ; elle disparaît dès que la * souris s'éloigne. * - * status: 'available' | 'owned' | 'taken' - * affordable: true si le joueur a assez de points dans la jauge liée à cet - * objet pour l'acheter maintenant (n'a de sens que si status='available'). - * Le bouton "ACHETER" n'est cliquable que si affordable est true — sinon - * il est grisé pour indiquer clairement pourquoi l'achat n'est pas possible. + * status: 'available' | 'owned' | 'taken' (objets/bâtiments), ou + * 'available' | 'disabled' (cartes Événement — voir kind='event' : le + * bouton "JOUER" est actif si affordable=true, grisé sinon, jamais + * "possédé/indisponible" puisqu'une carte événement en main appartient + * déjà entièrement au joueur qui la consulte). + * affordable: pour un objet/bâtiment, true si le joueur a assez de points + * pour l'ACHETER (bouton "ACHETER"). Pour un événement, true si la carte + * peut être JOUÉE maintenant (aucune autre carte événement déjà + * sélectionnée ce tour — bouton "JOUER"). * displayScale: échelle du JETON COMPACT uniquement (ex: 0.55 pour les mini * cartes des adversaires, 0.7 pour les miennes, 1 pour la boutique). La * carte détaillée affichée au survol n'hérite JAMAIS de cette échelle — * elle reste toujours à sa taille réelle pour rester lisible, quel que * soit le contexte d'où elle a été ouverte. - * kind: 'item' (défaut) ou 'building'. Un objet a un seul attribut - * (`itemData.attr`) et un coût scalaire (`itemData.cost`). Un bâtiment a - * DEUX attributs (`itemData.attrs`, tableau) et un coût réparti sur les - * deux (`itemData.cost`, objet `{attr: montant}`) — cette classe gère les - * deux formes via kind, plutôt que dupliquer tout le composant. + * kind: 'item' (défaut), 'building', ou 'event'. + * - 'item' : un seul attribut (`itemData.attr`), coût scalaire (`itemData.cost`). + * - 'building' : DEUX attributs (`itemData.attrs`, tableau), coût réparti + * sur les deux (`itemData.cost`, objet `{attr: montant}`). + * - 'event' : pas d'attribut/coût affiché (le coût de pioche est déjà payé + * à l'achat de la carte) ; bouton "JOUER" au lieu de "ACHETER", émet + * l'événement 'play' au lieu de 'buy'. + * Cette classe gère les trois formes via kind, plutôt que dupliquer tout + * le composant pour chacune. * hAnchor: 'left' (défaut) ou 'right'. Détermine de quel côté du jeton * compact la carte détaillée s'étend horizontalement au survol. 'left' = * la carte s'étend vers la DROITE (jeton ancré sur son coin gauche) — * adapté à la boutique d'objets sur le bord GAUCHE du canvas. 'right' = * la carte s'étend vers la GAUCHE (jeton ancré sur son coin droit) — - * nécessaire pour la boutique de bâtiments sur le bord DROIT du canvas, - * sinon la carte déborderait hors du canvas et son bouton ACHETER - * deviendrait injoignable au clic. + * nécessaire pour la boutique de bâtiments (et la main d'événements, + * également sur le bord DROIT) sur le bord DROIT du canvas, sinon la + * carte déborderait hors du canvas et son bouton deviendrait injoignable + * au clic. */ export class ItemBoardToken extends Phaser.GameObjects.Container { constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left') { @@ -89,14 +102,19 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { scene.add.existing(this) } - /** Attributs concernés par cet objet/bâtiment, toujours sous forme de tableau. */ + /** Attributs concernés par cet objet/bâtiment (tableau vide pour un événement). */ _attrs() { + if (this.kind === 'event') return [] return this.kind === 'building' ? this.itemData.attrs : [this.itemData.attr] } - /** Couleur représentative : celle de l'unique attribut pour un objet, ou un - * mélange des deux couleurs d'attribut pour un bâtiment. */ + /** Couleur représentative : celle de l'unique attribut pour un objet, un + * mélange des deux couleurs d'attribut pour un bâtiment, ou le doré + * "Événement" pour un événement. */ _attrColor() { + if (this.kind === 'event') { + return this.itemData.negative ? 0xcc2222 : 0xd4a732 + } const attrs = this._attrs() if (attrs.length === 1) return ATTR_COLORS[attrs[0]] ?? 0x888888 const c1 = ATTR_COLORS[attrs[0]] ?? 0x888888 @@ -108,7 +126,8 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { } /** Coût total affiché sur le badge : le coût lui-même pour un objet, la - * somme des coûts par attribut pour un bâtiment. */ + * somme des coûts par attribut pour un bâtiment. Un événement n'affiche + * pas de badge de coût (déjà payé au moment de la pioche). */ _costTotal() { if (this.kind === 'building') { return Object.values(this.itemData.cost).reduce((sum, c) => sum + c, 0) @@ -118,7 +137,9 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { /** Clé de texture portrait, préfixée différemment selon le type. */ _textureKey() { - return this.kind === 'building' ? `building-${this.itemData.id}` : `item-${this.itemData.id}` + if (this.kind === 'building') return `building-${this.itemData.id}` + if (this.kind === 'event') return `event-${this.itemData.id}` + return `item-${this.itemData.id}` } _buildCompact() { @@ -155,15 +176,35 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { border.strokeRoundedRect(-S / 2, -S / 2, S, S, 8) compact.add(border) - // Badge de coût (coin haut-droit) — coût total pour un bâtiment - const bx = S / 2 - 7, by = -S / 2 + 7 - const costRing = this.scene.add.graphics() - costRing.fillStyle(0x1a0a00, 1) - costRing.fillCircle(bx, by, 9) - compact.add(costRing) - compact.add(this.scene.add.text(bx, by, String(this._costTotal()), { - fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' - }).setOrigin(0.5)) + if (this.kind === 'event') { + // Pas de badge de coût (déjà payé à la pioche) ; à la place, un badge + // "!" pour signaler un événement à effet négatif — cohérent avec + // utile pour repérer vite les événements à risque avant de les jouer. + if (this.itemData.negative) { + const bx = S / 2 - 7, by = -S / 2 + 7 + const ring = this.scene.add.graphics() + ring.fillStyle(0x1a0a00, 1) + ring.fillCircle(bx, by, 9) + compact.add(ring) + const disk = this.scene.add.graphics() + disk.fillStyle(0xcc2222, 1) + disk.fillCircle(bx, by, 7) + compact.add(disk) + compact.add(this.scene.add.text(bx, by, '!', { + fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' + }).setOrigin(0.5)) + } + } else { + // Badge de coût (coin haut-droit) — coût total pour un bâtiment + const bx = S / 2 - 7, by = -S / 2 + 7 + const costRing = this.scene.add.graphics() + costRing.fillStyle(0x1a0a00, 1) + costRing.fillCircle(bx, by, 9) + compact.add(costRing) + compact.add(this.scene.add.text(bx, by, String(this._costTotal()), { + fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff' + }).setOrigin(0.5)) + } if (this.status === 'owned') { const check = this.scene.add.graphics() @@ -315,7 +356,10 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { border.strokeRoundedRect(-EXPANDED_W / 2, -cardH / 2, EXPANDED_W, cardH, 12) c.add(border) - if (!isAvailable) { + // Bande de statut "POSSÉDÉ"/"INDISPONIBLE" — ne concerne que les objets + // et bâtiments (stock unique partagé). Une carte Événement en main + // n'a pas cette notion : elle appartient déjà entièrement au joueur. + if (this.kind !== 'event' && !isAvailable) { const bandColor = this.status === 'owned' ? 0x2e7d32 : 0x555555 const bandText = this.status === 'owned' ? '✔ POSSÉDÉ' : 'INDISPONIBLE' const band = this.scene.add.graphics() @@ -327,35 +371,40 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { }).setOrigin(0.5)) } - // ── Bouton ACHETER : cliquable uniquement si affordable, sinon grisé ───── - // Distinct du reste de la carte (qui ne fait qu'afficher les infos) pour - // que le joueur voie explicitement s'il PEUT acheter maintenant ou non, - // au lieu de deviner en cliquant n'importe où sur la carte. + // ── Bouton d'action : "ACHETER" (objet/bâtiment) ou "JOUER" (événement) ── + // Cliquable uniquement si affordable, sinon grisé. Distinct du reste de + // la carte (qui ne fait qu'afficher les infos) pour que le joueur voie + // explicitement s'il PEUT agir maintenant ou non, au lieu de deviner en + // cliquant n'importe où sur la carte. if (isAvailable) { + const isEvent = this.kind === 'event' const btnY = cardH / 2 - BUY_BTN_H / 2 - 8 const btnW = EXPANDED_W - 20 - const canBuy = this.affordable + const canAct = this.affordable + const actionLabel = isEvent ? 'JOUER' : 'ACHETER' + const disabledLabel = isEvent ? 'INDISPONIBLE' : 'INSUFFISANT' + const eventName = isEvent ? 'play' : 'buy' const btnBg = this.scene.add.graphics() - btnBg.fillStyle(canBuy ? 0x2e7d32 : 0x3a3a3a, 1) + btnBg.fillStyle(canAct ? 0x2e7d32 : 0x3a3a3a, 1) btnBg.fillRoundedRect(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H, 6) - btnBg.lineStyle(2, canBuy ? 0x7CFC9A : 0x666666, 1) + btnBg.lineStyle(2, canAct ? 0x7CFC9A : 0x666666, 1) btnBg.strokeRoundedRect(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H, 6) c.add(btnBg) - const btnText = this.scene.add.text(0, btnY, canBuy ? 'ACHETER' : 'INSUFFISANT', { + const btnText = this.scene.add.text(0, btnY, canAct ? actionLabel : disabledLabel, { fontSize: '12px', fontFamily: 'Georgia', fontStyle: 'bold', - color: canBuy ? '#ffffff' : '#999999' + color: canAct ? '#ffffff' : '#999999' }).setOrigin(0.5) c.add(btnText) - if (canBuy) { + if (canAct) { btnBg.setInteractive( new Phaser.Geom.Rectangle(-btnW / 2, btnY - BUY_BTN_H / 2, btnW, BUY_BTN_H), Phaser.Geom.Rectangle.Contains ) btnBg.input.cursor = 'pointer' - btnBg.on('pointerdown', () => this.emit('buy', this.itemData)) + btnBg.on('pointerdown', () => this.emit(eventName, this.itemData)) btnBg.on('pointerover', () => { this._cancelHide(); btnBg.setScale(1.03) }) btnBg.on('pointerout', () => { this._scheduleHide(); btnBg.setScale(1) }) } diff --git a/client/src/game/objects/eventHandLayout.js b/client/src/game/objects/eventHandLayout.js index 08b66f3..0e546df 100644 --- a/client/src/game/objects/eventHandLayout.js +++ b/client/src/game/objects/eventHandLayout.js @@ -1,6 +1,6 @@ /** * Calculs purs de géométrie pour la zone "cartes Événement" (bouton de - * pioche + main de cartes événement, max 2), extraits de GameScene pour + * pioche + main de jetons événement, max 2), extraits de GameScene pour * être testables unitairement sans dépendre de Phaser/Canvas. * * Placement choisi : bas-DROITE de l'écran, clairement séparé du bouton @@ -8,14 +8,19 @@ * (centrée en bas) pour éviter toute confusion entre "jouer une carte * personnage" et "jouer une carte événement" — un chevauchement précédent * plaçait le bouton de pioche à ~25px du bord du bouton de confirmation. + * + * Les cartes événement utilisent le même composant que les objets/bâtiments + * (ItemBoardToken) : un petit jeton compact (ICON_SIZE, voir ItemBoardToken.js) + * toujours visible, avec la carte détaillée affichée en taille réelle au + * survol — pas de grande carte réduite à l'échelle, dont le texte devenait + * illisible. */ -import { CARD_W, CARD_H } from './cardLayout' - -// Échelle appliquée aux cartes événement en main. Volontairement proche de 1 -// (0.8, pas 0.55 comme l'ancien placement) pour que le nom de l'événement -// (police 12px de base) reste réellement lisible une fois mis à l'échelle. -export const EVENT_CARD_SCALE = 0.8 +// Import depuis tokenLayout.js (pas ItemBoardToken.js) : ce module reste +// ainsi totalement indépendant de Phaser, testable en Vitest/jsdom sans +// jamais déclencher l'import de `phaser` (qui échoue en environnement de +// test faute de canvas natif — voir tokenLayout.js). +import { ICON_SIZE } from './tokenLayout' // Marge depuis les bords droit/bas du canvas. const MARGIN_RIGHT = 20 @@ -25,38 +30,40 @@ const MARGIN_BOTTOM = 60 // au-dessus de BOTTOM_MARGIN (barre Windows) + marge d const DRAW_BUTTON_H = 30 const GAP_BUTTON_HAND = 14 +// Espacement entre 2 jetons événement adjacents (même logique que les +// colonnes d'objets/bâtiments : ICON_SIZE + marge). +const HAND_SPACING = ICON_SIZE + 14 + /** * Position du bouton "Piocher un Événement", ancré en bas à droite du - * canvas, au-dessus de la main de cartes événement elle-même. + * canvas, au-dessus de la main de jetons événement elle-même. */ export function eventDrawButtonPosition(canvasWidth, canvasHeight, buttonWidth = 190) { const x = canvasWidth - MARGIN_RIGHT - buttonWidth / 2 - const y = canvasHeight - MARGIN_BOTTOM - (CARD_H * EVENT_CARD_SCALE) - GAP_BUTTON_HAND - DRAW_BUTTON_H / 2 + const y = canvasHeight - MARGIN_BOTTOM - ICON_SIZE - GAP_BUTTON_HAND - DRAW_BUTTON_H / 2 return { x, y } } /** - * Géométrie de la main de cartes événement (jusqu'à 2), positionnée juste - * sous le bouton de pioche, alignée sur le bord droit du canvas (les cartes + * Géométrie de la main de jetons événement (jusqu'à 2), positionnée juste + * sous le bouton de pioche, alignée sur le bord droit du canvas (les jetons * s'empilent horizontalement vers la GAUCHE depuis le bord droit). */ export function eventHandGeometry(canvasWidth, canvasHeight, cardCount) { - const cardW = CARD_W * EVENT_CARD_SCALE - const cardH = CARD_H * EVENT_CARD_SCALE - const spacing = cardW + 10 + const spacing = HAND_SPACING + const y = canvasHeight - MARGIN_BOTTOM - ICON_SIZE / 2 - const y = canvasHeight - MARGIN_BOTTOM - cardH / 2 - // Ancrées sur le bord droit : la dernière carte (index cardCount-1) a son - // bord droit à `canvasWidth - MARGIN_RIGHT`, les précédentes s'étendent + // Ancrés sur le bord droit : le dernier jeton (index cardCount-1) a son + // bord droit à `canvasWidth - MARGIN_RIGHT`, les précédents s'étendent // vers la gauche. const rightEdge = canvasWidth - MARGIN_RIGHT - const lastCardCenterX = rightEdge - cardW / 2 - const startX = lastCardCenterX - Math.max(cardCount - 1, 0) * spacing + const lastCenterX = rightEdge - ICON_SIZE / 2 + const startX = lastCenterX - Math.max(cardCount - 1, 0) * spacing - return { startX, y, spacing, scale: EVENT_CARD_SCALE } + return { startX, y, spacing } } -/** Position (x, y) de la carte d'index `i` dans la main d'événements. */ +/** Position (x, y) du jeton d'index `i` dans la main d'événements. */ export function eventCardPosition(canvasWidth, canvasHeight, cardCount, index) { const { startX, y, spacing } = eventHandGeometry(canvasWidth, canvasHeight, cardCount) return { x: startX + index * spacing, y } diff --git a/client/src/game/objects/tokenLayout.js b/client/src/game/objects/tokenLayout.js new file mode 100644 index 0000000..f3d2ab5 --- /dev/null +++ b/client/src/game/objects/tokenLayout.js @@ -0,0 +1,10 @@ +/** + * Taille du jeton compact utilisé par ItemBoardToken (objets, bâtiments, + * cartes Événement). Extraite dans son propre module SANS dépendance à + * Phaser (contrairement à ItemBoardToken.js qui importe `phaser`), pour + * pouvoir être importée par des modules de calcul de géométrie purs (ex: + * eventHandLayout.js) et testée unitairement avec Vitest sans échouer à + * charger Phaser dans l'environnement de test (jsdom n'a pas de canvas + * natif, ce que Phaser requiert dès son import). + */ +export const ICON_SIZE = 46 diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index dbb6371..62ac8b7 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -2,8 +2,7 @@ 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 { eventDrawButtonPosition, eventHandGeometry, EVENT_CARD_SCALE } from '../objects/eventHandLayout' +import { eventDrawButtonPosition, eventHandGeometry } from '../objects/eventHandLayout' import { ITEMS, getItemById } from '../items' import { BUILDINGS, getBuildingById } from '../buildings' import { EVENTS, getEventById, DRAW_COST } from '../events' @@ -64,9 +63,13 @@ export class GameScene extends Phaser.Scene { 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) + // ── Cartes Événement (seconde main, max 2, bas-droite du plateau) ────────── + // Même pattern que les objets/bâtiments (ItemBoardToken) : petit jeton + // compact toujours visible, carte détaillée en taille réelle au survol + // (bien plus lisible que l'ancien affichage en grande carte réduite à + // 0.8, dont le texte restait écrasé). + this.eventCards = [] // ItemBoardToken (kind='event') actuellement affichées (ma main) + this.selectedEventCard = null // ItemBoardToken choisie pour être jouée ce tour (avant confirmation) this._lastEventHandKey = null // pour ne reconstruire qu'au vrai changement de main d'événements this._pendingEventTargets = {} // cibles choisies pour l'événement en cours de sélection/ciblage } @@ -257,10 +260,11 @@ export class GameScene extends Phaser.Scene { 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). + // AU-DESSUS de son slot de jeu (comme si elles étaient posées en + // évidence sur sa carte plutôt que cachées en dessous, plus proche du + // bord de l'écran). 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 ?? [] @@ -271,17 +275,17 @@ export class GameScene extends Phaser.Scene { 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) + 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 + // Bâtiments possédés, sur une rangée encore au-dessus des 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') + const token = new ItemBoardToken(this, startBuildingX + k * tokenGap, slotY - 152, building, 'owned', false, 0.55, 'building') this.opponentBuildingTokens.push(token) }) } @@ -733,10 +737,12 @@ export class GameScene extends Phaser.Scene { /** * (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), en - * bas-droite du canvas, à une échelle (0.8, voir eventHandLayout.js) - * suffisamment proche de 1 pour que le nom de l'événement reste lisible - * — un placement précédent utilisait 0.55, rendant le texte (12px de - * base) quasi illisible une fois réduit. + * bas-droite du canvas. Même composant que les objets/bâtiments + * (ItemBoardToken, kind='event') : un petit jeton compact toujours + * visible, avec la carte détaillée (nom + description en taille réelle, + * bien plus lisible) affichée au survol et un bouton "JOUER" — l'ancien + * Le précédent composant dédié affichait une grande carte réduite à + * l'échelle 0.8, ce qui écrasait le texte au point de le rendre illisible. */ _buildEventHand(eventIds) { this.eventCards.forEach(c => c.destroy()) @@ -746,16 +752,24 @@ export class GameScene extends Phaser.Scene { if (!eventIds || eventIds.length === 0) return const { width, height } = this.scale - const { startX, y, spacing, scale } = eventHandGeometry(width, height, eventIds.length) + const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length) eventIds.forEach((eventId, i) => { const eventData = getEventById(eventId) if (!eventData) return - const card = new EventCardObject(this, startX + i * spacing, y, eventData) - card.setScale(scale) - card.on('select', this._onEventCardSelected, this) - this.eventCards.push(card) + // affordable=true : le bouton "JOUER" est activé tant qu'aucune autre + // carte événement n'a déjà été choisie ce tour (voir + // _refreshEventHandAffordability, appelé après chaque reconstruction + // et à chaque game-state pour refléter pendingEventPlayId). + // hAnchor='right' : la main est ancrée sur le bord DROIT du canvas + // (voir eventHandLayout.js), la carte détaillée doit donc s'étendre + // vers la GAUCHE au survol pour ne jamais déborder hors du canvas. + const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', true, 1, 'event', 'right') + token.on('play', (data) => this._onEventCardPlay(token, data)) + this.eventCards.push(token) }) + + this._refreshEventHandAffordability() } _layoutEventHand() { @@ -764,51 +778,71 @@ export class GameScene extends Phaser.Scene { const { startX, y, spacing } = eventHandGeometry(width, height, this.eventCards.length) 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). + * Le bouton "JOUER" de chaque carte événement en main doit être désactivé + * dès qu'une carte a déjà été choisie ce tour (une seule autorisée) ou + * qu'une séquence de ciblage est en cours — reconstruit les jetons pour + * refléter l'état courant (ItemBoardToken.affordable est fixé à la + * construction, pas réactif après coup). + */ + _refreshEventHandAffordability() { + const locked = !!this.selectedEventCard || !!this._targetingState + if (!locked) return // déjà construits avec affordable=true par défaut + + const eventIds = this.eventCards.map(c => c.itemData.id) + const { width, height } = this.scale + const { startX, y, spacing } = eventHandGeometry(width, height, eventIds.length) + + this.eventCards.forEach(c => c.destroy()) + this.eventCards = eventIds.map((eventId, i) => { + const eventData = getEventById(eventId) + const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', false, 1, 'event', 'right') + token.on('play', (data) => this._onEventCardPlay(token, data)) + return token + }) + } + + /** + * Clic sur le bouton "JOUER" d'une carte événement de ma main (une seule + * autorisée par tour). Si l'événement nécessite un ciblage, ouvre la + * séquence de ciblage (clic sur les slots adversaires) avant d'émettre + * 'play-event' — sinon l'émission est immédiate. * * 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 + * piocher") est en réalité demandé ici, au moment de JOUER la carte, + * 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) { + _onEventCardPlay(token, eventData) { 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()) + const needsTarget = eventData.needsTargetOnDraw || + (eventData.needsTargetOnPlay && this._epidemicNeedsTarget()) if (!needsTarget) { - this._confirmPlayEvent(card, {}) + this._confirmPlayEvent(token, {}) return } - if (event.id === 'royal_accord') { + if (eventData.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'], { + this._startTargeting(token, ['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'], { + } else if (eventData.id === 'epidemic') { + this._startTargeting(token, ['targetTeamIndex'], { targetTeamIndex: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)' }) } @@ -830,9 +864,10 @@ export class GameScene extends Phaser.Scene { * avance jusqu'à ce que toutes les clés de `keys` soient renseignées, puis * émet 'play-event' avec les cibles collectées. */ - _startTargeting(card, keys, instructions) { - this._targetingState = { card, keys: [...keys], instructions, collected: {} } + _startTargeting(token, keys, instructions) { + this._targetingState = { token, keys: [...keys], instructions, collected: {} } this._highlightOpponentSlots(true) + this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes pendant le ciblage this.phaseText.setText(instructions[keys[0]]) } @@ -875,7 +910,7 @@ export class GameScene extends Phaser.Scene { _onTargetSlotClicked(targetTeamIndex) { if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return - const { card, keys, instructions, collected } = this._targetingState + const { token, keys, instructions, collected } = this._targetingState const currentKey = keys[0] collected[currentKey] = targetTeamIndex keys.shift() @@ -884,7 +919,7 @@ export class GameScene extends Phaser.Scene { this._highlightOpponentSlots(false) const targets = this._targetingState.collected this._targetingState = null - this._confirmPlayEvent(card, targets) + this._confirmPlayEvent(token, targets) } else { this.phaseText.setText(instructions[keys[0]]) } @@ -897,12 +932,12 @@ export class GameScene extends Phaser.Scene { this._targetingState = null } - _confirmPlayEvent(card, targets) { - card.select() - this.selectedEventCard = card + _confirmPlayEvent(token, targets) { + this.selectedEventCard = token + this._refreshEventHandAffordability() // grise le bouton JOUER des autres cartes this.socket.emit('play-event', { roomId: this.roomId, - eventId: card.eventData.id, + eventId: token.itemData.id, targets }) } @@ -1131,6 +1166,16 @@ export class GameScene extends Phaser.Scene { this._lastEventHandKey = myEventHandKey this._buildEventHand(state.myEventHand ?? []) } + // Synchroniser l'état "carte déjà choisie ce tour" avec le serveur + // (utile après reconnexion, ou si l'action locale a précédé la + // confirmation serveur) pour maintenir le bouton JOUER des autres + // cartes grisé de façon cohérente. + if (state.pendingEventPlayId && !this.selectedEventCard) { + this.selectedEventCard = true + this._refreshEventHandAffordability() + } else if (!state.pendingEventPlayId && this.selectedEventCard) { + this.selectedEventCard = null + } this._refreshEventDrawButtonState(me?.gauges, (state.myEventHand ?? []).length, !!state.hasPendingEventDraw) if (state.phase === 'selecting') { diff --git a/client/test/unit/eventHandLayout.test.js b/client/test/unit/eventHandLayout.test.js index 0432f7a..b0aca8c 100644 --- a/client/test/unit/eventHandLayout.test.js +++ b/client/test/unit/eventHandLayout.test.js @@ -2,10 +2,9 @@ import { describe, it, expect } from 'vitest' import { eventDrawButtonPosition, eventHandGeometry, - eventCardPosition, - EVENT_CARD_SCALE + eventCardPosition } from '../../src/game/objects/eventHandLayout' -import { CARD_W, CARD_H } from '../../src/game/objects/cardLayout' +import { ICON_SIZE } from '../../src/game/objects/tokenLayout' // Dimensions de canvas typiques utilisées dans les tests (cohérentes avec // GameFactory.js : width = innerWidth, height = innerHeight - 60). @@ -13,13 +12,6 @@ const CANVAS_W = 1920 const CANVAS_H = 1080 describe('game/objects/eventHandLayout', () => { - describe('EVENT_CARD_SCALE', () => { - it('reste suffisamment proche de 1 pour garder le texte lisible (pas 0.55 comme l\'ancien placement)', () => { - expect(EVENT_CARD_SCALE).toBeGreaterThanOrEqual(0.7) - expect(EVENT_CARD_SCALE).toBeLessThanOrEqual(1) - }) - }) - describe('eventDrawButtonPosition', () => { it('positionne le bouton dans le quart bas-droit du canvas', () => { const { x, y } = eventDrawButtonPosition(CANVAS_W, CANVAS_H) @@ -36,13 +28,12 @@ describe('game/objects/eventHandLayout', () => { expect(y + buttonHeight / 2).toBeLessThanOrEqual(CANVAS_H) }) - it('est positionné strictement au-dessus de la main de cartes événement (pas de chevauchement)', () => { + it('est positionné strictement au-dessus de la main de jetons événement (pas de chevauchement)', () => { const buttonHeight = 30 const { y: btnY } = eventDrawButtonPosition(CANVAS_W, CANVAS_H) const { y: handY } = eventHandGeometry(CANVAS_W, CANVAS_H, 1) - const cardH = CARD_H * EVENT_CARD_SCALE const buttonBottom = btnY + buttonHeight / 2 - const handTop = handY - cardH / 2 + const handTop = handY - ICON_SIZE / 2 expect(buttonBottom).toBeLessThanOrEqual(handTop) }) @@ -74,22 +65,20 @@ describe('game/objects/eventHandLayout', () => { expect(y).toBeGreaterThan(CANVAS_H / 2) }) - it('la dernière carte est ancrée sur le bord droit du canvas (avec la marge)', () => { - const { startX, spacing, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) - const cardW = CARD_W * scale - const lastCardCenterX = startX + spacing // index 1 = dernière carte pour cardCount=2 - const lastCardRightEdge = lastCardCenterX + cardW / 2 - expect(lastCardRightEdge).toBeLessThanOrEqual(CANVAS_W) - expect(lastCardRightEdge).toBeGreaterThan(CANVAS_W - 40) // proche du bord (marge ~20px) + it('le dernier jeton est ancré sur le bord droit du canvas (avec la marge)', () => { + const { startX, spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + const lastCenterX = startX + spacing // index 1 = dernier jeton pour cardCount=2 + const lastRightEdge = lastCenterX + ICON_SIZE / 2 + expect(lastRightEdge).toBeLessThanOrEqual(CANVAS_W) + expect(lastRightEdge).toBeGreaterThan(CANVAS_W - 40) // proche du bord (marge ~20px) }) - it('avec 1 carte, elle est ancrée sur le bord droit (pas de décalage vers la gauche)', () => { + it('avec 1 jeton, il est ancré sur le bord droit (pas de décalage vers la gauche)', () => { const { startX } = eventHandGeometry(CANVAS_W, CANVAS_H, 1) - const { startX: startXTwo } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) - // La position de la DERNIÈRE carte doit être identique quel que soit - // le nombre de cartes (ancrage à droite stable) — avec 1 carte, - // startX doit correspondre à la position de la dernière carte du cas à 2. - const { spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + const { startX: startXTwo, spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + // La position du DERNIER jeton doit être identique quel que soit le + // nombre de jetons (ancrage à droite stable) — avec 1 jeton, startX + // doit correspondre à la position du dernier jeton du cas à 2. expect(startX).toBeCloseTo(startXTwo + spacing, 5) }) @@ -100,27 +89,24 @@ describe('game/objects/eventHandLayout', () => { expect(Number.isFinite(spacing)).toBe(true) }) - it('l\'espacement entre cartes est toujours positif et couvre au moins la largeur d\'une carte', () => { - const { spacing, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) - expect(spacing).toBeGreaterThan(CARD_W * scale) + it('l\'espacement entre jetons est toujours positif et couvre au moins la largeur d\'un jeton', () => { + const { spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + expect(spacing).toBeGreaterThan(ICON_SIZE) }) - it('les 2 cartes ne se chevauchent jamais (distance entre centres >= largeur d\'une carte)', () => { - const { spacing, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) - const cardW = CARD_W * scale - expect(spacing).toBeGreaterThanOrEqual(cardW) + it('les 2 jetons ne se chevauchent jamais (distance entre centres >= largeur d\'un jeton)', () => { + const { spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + expect(spacing).toBeGreaterThanOrEqual(ICON_SIZE) }) it('reste entièrement dans le canvas sur une petite résolution (1280x720)', () => { - const { startX, y, spacing, scale } = eventHandGeometry(1280, 720, 2) - const cardW = CARD_W * scale - const cardH = CARD_H * scale - const firstCardLeftEdge = startX - cardW / 2 - const lastCardRightEdge = startX + spacing + cardW / 2 - expect(firstCardLeftEdge).toBeGreaterThanOrEqual(0) - expect(lastCardRightEdge).toBeLessThanOrEqual(1280) - expect(y + cardH / 2).toBeLessThanOrEqual(720) - expect(y - cardH / 2).toBeGreaterThanOrEqual(0) + const { startX, y, spacing } = eventHandGeometry(1280, 720, 2) + const firstLeftEdge = startX - ICON_SIZE / 2 + const lastRightEdge = startX + spacing + ICON_SIZE / 2 + expect(firstLeftEdge).toBeGreaterThanOrEqual(0) + expect(lastRightEdge).toBeLessThanOrEqual(1280) + expect(y + ICON_SIZE / 2).toBeLessThanOrEqual(720) + expect(y - ICON_SIZE / 2).toBeGreaterThanOrEqual(0) }) }) @@ -133,7 +119,7 @@ describe('game/objects/eventHandLayout', () => { expect(pos1).toEqual({ x: startX + spacing, y }) }) - it('les positions de 2 cartes sont distinctes', () => { + it('les positions de 2 jetons sont distinctes', () => { const pos0 = eventCardPosition(CANVAS_W, CANVAS_H, 2, 0) const pos1 = eventCardPosition(CANVAS_W, CANVAS_H, 2, 1) expect(pos0.x).not.toBe(pos1.x) @@ -142,13 +128,12 @@ describe('game/objects/eventHandLayout', () => { describe('non-régression : séparation avec la légende d\'attributs (bas-gauche)', () => { it('la zone événement (bas-droite) ne recouvre pas la zone légende (bas-gauche, x proche de 0)', () => { - const { startX, scale } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) - const cardW = CARD_W * scale - const firstCardLeftEdge = startX - cardW / 2 + const { startX } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + const firstLeftEdge = startX - ICON_SIZE / 2 // La légende occupe le coin bas-gauche (x ancré à 10px du bord gauche, // largeur de texte ~180px max) — la zone événement doit rester dans // la moitié droite du canvas pour ne jamais s'en approcher. - expect(firstCardLeftEdge).toBeGreaterThan(CANVAS_W / 2) + expect(firstLeftEdge).toBeGreaterThan(CANVAS_W / 2) }) }) }) diff --git a/e2e/tests/helpers.js b/e2e/tests/helpers.js index 14074e2..04d4411 100644 --- a/e2e/tests/helpers.js +++ b/e2e/tests/helpers.js @@ -325,6 +325,13 @@ export async function clickEventDrawButton(page) { * puisse faire progresser les tours pendant l'accumulation. */ export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) { + // Événements sans ciblage requis (au pire un ciblage optionnel côté + // Épidémie, déclenché seulement avec la Trousse — jamais possédée dans un + // scénario de test frais). Utilisés pour vider un slot de la main sans + // jamais déclencher une séquence de ciblage qui resterait bloquée faute + // de clic sur un slot adverse (playEventCard sans targetTeamIndexes). + const SAFE_TO_DISCARD = new Set(['bountiful_harvest', 'troop_levy', 'plot_uncovered', 'castle_feast', 'epidemic']) + for (let attempt = 0; attempt < maxDraws; attempt++) { const hand = await getMyEventHand(page) if (hand.includes(eventId)) return true @@ -334,11 +341,30 @@ export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) { // rendait ce helper très peu fiable — 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), jouer l'une des 2 cartes en main pour - // libérer un slot et pouvoir continuer à piocher. On joue la première - // carte sans ciblage particulier ; si elle nécessite un ciblage - // (royal_accord/epidemic), on tente sans cible plutôt que de bloquer - // le nettoyage de la main. - await playEventCard(page, hand[0]) + // libérer un slot et pouvoir continuer à piocher. + // IMPORTANT : ne JAMAIS choisir 'royal_accord' pour cette défausse — + // il exige un ciblage séquentiel à 2 étapes (gain + perte) ; le jouer + // sans fournir targetTeamIndexes laisserait la séquence de ciblage + // bloquée côté client (aucun 'play-event' jamais émis), et donc la + // main jamais libérée, causant l'échec systématique observé. + const discardable = hand.find(id => SAFE_TO_DISCARD.has(id)) ?? hand[0] + if (discardable === 'royal_accord') { + // Les 2 cartes en main sont 'royal_accord' (cas rare) : impossible + // de la défausser sans ciblage séquentiel complet. On cible le + // premier adversaire réel (n'importe quel autre teamIndex que le + // mien) pour les deux étapes — le ciblage doit obligatoirement + // pointer sur un adversaire pour que le clic sur le slot déclenche + // réellement _onTargetSlotClicked (les highlights ne sont posés que + // sur les slots adverses, jamais sur le mien). + const myTeamIndex = await page.evaluate(() => { + const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1] + return game?.scene?.getScene('GameScene')?.store?.myTeamIndex ?? 0 + }) + const opponentTeamIndex = myTeamIndex === 0 ? 1 : 0 + await playEventCard(page, discardable, [opponentTeamIndex, opponentTeamIndex]) + } else { + await playEventCard(page, discardable) + } await playFullTurn([page, ...otherPages]) continue } @@ -360,28 +386,49 @@ export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) { } /** - * Sélectionne (clique) une carte de ma main d'événements pour la jouer ce - * tour. Si l'événement nécessite un ciblage, fournir `targetTeamIndexes` + * Joue une carte de ma main d'événements ce tour. Les cartes événement + * utilisent désormais le même composant que les objets/bâtiments + * (ItemBoardToken, kind='event') : un jeton compact toujours visible, dont + * il faut survoler pour révéler la carte détaillée, puis cliquer sur son + * bouton "JOUER" — plus un simple clic direct sur une grande carte comme + * l'ancien EventCardObject. + * + * Si l'événement nécessite un ciblage, fournir `targetTeamIndexes` * (tableau, dans l'ordre attendu par GameScene._startTargeting : pour * royal_accord [gainTargetTeamIndex, loseTargetTeamIndex], pour epidemic * [targetTeamIndex]) — les clics correspondants sur les slots adversaires - * sont effectués automatiquement. + * sont effectués automatiquement après le clic sur "JOUER". */ export async function playEventCard(page, eventId, targetTeamIndexes = []) { const canvas = page.locator('#phaser-container canvas') const box = await canvas.boundingBox() - const cardPos = await page.evaluate((eventId) => { + const tokenPos = await page.evaluate((eventId) => { const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1] const scene = game?.scene?.getScene('GameScene') - const card = scene?.eventCards?.find(c => c.eventData.id === eventId) - if (!card) return null - const b = card.getBounds() - return { x: b.centerX, y: b.centerY } + const token = scene?.eventCards?.find(c => c.itemData.id === eventId) + return token ? { x: token.x, y: token.y } : null }, eventId) - if (!cardPos) return false + if (!tokenPos) return false - await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y) + await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y) + await page.waitForTimeout(300) + + const btnCenter = await page.evaluate((eventId) => { + const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1] + const scene = game?.scene?.getScene('GameScene') + const token = scene?.eventCards?.find(c => c.itemData.id === eventId) + if (!token || !token._expanded) return null + const btnBg = token._expanded.list.find(o => o.type === 'Graphics' && o.input) + if (!btnBg) return null // bouton non interactif (une autre carte déjà jouée ce tour) + const worldPoint = btnBg.getWorldTransformMatrix().transformPoint( + btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY + ) + return { x: worldPoint.x, y: worldPoint.y } + }, eventId) + if (!btnCenter) return false + + await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y) await page.waitForTimeout(250) for (const teamIndex of targetTeamIndexes) {