fix: cartes Événement illisibles + objets/bâtiments adversaires au-dessus de leur carte
Deux problèmes de lisibilité/placement signalés : 1. Le texte des cartes Événement (nom + description) restait illisible malgré le passage précédent de l'échelle 0.55 à 0.8 — le composant dédié EventCardObject affichait une grande carte réduite, écrasant systématiquement le texte quelle que soit l'échelle choisie. Fix : suppression complète d'EventCardObject, remplacé par le même pattern que les objets/bâtiments (ItemBoardToken, nouveau kind='event') : un petit jeton compact (46x46) toujours visible sur le plateau, avec la carte détaillée affichée en TAILLE RÉELLE (police 13-16px, jamais réduite) au survol de la souris, et un bouton "JOUER" (au lieu d'"ACHETER") pour confirmer. ItemBoardToken généralisé pour supporter ce 3e kind (pas de badge de coût affiché — déjà payé à la pioche — mais badge "!" repris pour les événements à effet négatif type Épidémie). Extraction de client/src/game/objects/tokenLayout.js (ICON_SIZE) dans un module SANS dépendance à Phaser, pour que eventHandLayout.js (et ses tests Vitest) puisse l'importer sans jamais charger Phaser — qui échoue à l'import en environnement jsdom (canvas natif requis, absent de jsdom). 2. Les mini-cartes objets/bâtiments déjà achetés par un ADVERSAIRE étaient affichées SOUS son slot de jeu, visuellement en dessous/après sa carte jouée. Déplacées AU-DESSUS de son slot pour les mettre davantage en évidence, comme posées sur sa carte plutôt que cachées dessous. - 43 tests unitaires client mis à jour (eventHandLayout.test.js migré vers ICON_SIZE au lieu de CARD_W/CARD_H/scale, désormais indépendant de Phaser). - e2e/tests/helpers.js : playEventCard adapté au nouveau flux survol->bouton JOUER (au lieu d'un simple clic direct sur la carte) ; drawEventUntil corrigé pour ne jamais défausser une carte 'royal_accord' sans fournir ses 2 cibles de ciblage séquentiel obligatoire (le jeu sans cible bloquait silencieusement la séquence, sans jamais émettre 'play-event', rendant la boucle de pioche très peu fiable). - Vérification ciblée via un test E2E existant (pas de nouvelle suite E2E lourde, conformément à la demande de privilégier les tests unitaires) : le nouveau flux jeton->survol->JOUER->résolution fonctionne bout en bout (Récolte exceptionnelle +2 Nourriture confirmé après résolution).
This commit is contained in:
1 parent
45eb9be60d
commit
1a91a0f876
7 files changed
+316
-309
No files matched your search
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
+62
-15
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user