From 45eb9be60d705ab0131ee15217aa3882cfbf8377 Mon Sep 17 00:00:00 2001 From: Kevin L'Hereec Date: Mon, 24 Aug 2026 14:52:18 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20repositionner=20les=20cartes=20=C3=89v?= =?UTF-8?q?=C3=A9nement=20en=20bas-droite=20avec=20un=20texte=20lisible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les cartes Événement (bouton de pioche + main) étaient positionnées trop près du bouton "CONFIRMER MA CARTE" — le bouton de pioche chevauchait même physiquement son bord droit de ~25px (x = width/2 + 210, même Y), au point de prêter à confusion entre "piocher un événement" et "jouer une carte". - Nouveau module pur client/src/game/objects/eventHandLayout.js (eventDrawButtonPosition, eventHandGeometry, eventCardPosition) : ancre toute la zone événement en bas-DROITE du canvas, clairement séparée du bouton de confirmation (centré) et de la main de personnages (centrée). - EVENT_CARD_SCALE remonté de 0.55 à 0.8 pour que le nom de l'événement (police de base augmentée 12px -> 15px) reste réellement lisible une fois réduit — ajout d'un bandeau semi-opaque derrière le texte (EventCardObject) pour garantir le contraste quel que soit le portrait en arrière-plan. - La légende des attributs occupait déjà le coin bas-droit : déplacée en bas-GAUCHE (setOrigin ajusté en conséquence) pour laisser la place à la nouvelle zone événement. - 16 nouveaux tests unitaires Vitest (eventHandLayout.test.js) couvrant la géométrie : positions dans les limites du canvas, absence de chevauchement avec le bouton de confirmation/la main/la légende, ancrage stable au bord droit avec 1 ou 2 cartes, robustesse sur petite résolution (1280x720) et cardCount=0. Choix explicite de tests UNITAIRES (rapides, ~600ms pour toute la suite client) plutôt que E2E pour cette correction de positionnement pur, les tests E2E de ce projet étant lents (plusieurs minutes par scénario à cause de la pioche aléatoire à stabiliser). --- client/src/game/objects/EventCardObject.js | 19 ++- client/src/game/objects/eventHandLayout.js | 63 +++++++++ client/src/game/scenes/GameScene.js | 53 ++++--- client/test/unit/eventHandLayout.test.js | 154 +++++++++++++++++++++ 4 files changed, 263 insertions(+), 26 deletions(-) create mode 100644 client/src/game/objects/eventHandLayout.js create mode 100644 client/test/unit/eventHandLayout.test.js diff --git a/client/src/game/objects/EventCardObject.js b/client/src/game/objects/EventCardObject.js index c5fb744..b7b972e 100644 --- a/client/src/game/objects/EventCardObject.js +++ b/client/src/game/objects/EventCardObject.js @@ -55,18 +55,29 @@ export class EventCardObject extends Phaser.GameObjects.Container { border.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS) this.add(border) - // ── 4. Nom en bas ────────────────────────────────────────────────────────── + // ── 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 - 14, + 0, CARD_H / 2 - nameBandH / 2 - 1, this.eventData.name, { - fontSize: '12px', + fontSize: '15px', fontFamily: 'Georgia, serif', fontStyle: 'bold', color: '#f5e6c8', stroke: '#000000', strokeThickness: 3, align: 'center', - wordWrap: { width: CARD_W - 12 } + lineSpacing: 2, + wordWrap: { width: CARD_W - 10 } } ).setOrigin(0.5, 0.5)) diff --git a/client/src/game/objects/eventHandLayout.js b/client/src/game/objects/eventHandLayout.js new file mode 100644 index 0000000..08b66f3 --- /dev/null +++ b/client/src/game/objects/eventHandLayout.js @@ -0,0 +1,63 @@ +/** + * 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 + * être testables unitairement sans dépendre de Phaser/Canvas. + * + * Placement choisi : bas-DROITE de l'écran, clairement séparé du bouton + * "CONFIRMER MA CARTE" (centré en bas) et de la main de personnages + * (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. + */ + +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 + +// Marge depuis les bords droit/bas du canvas. +const MARGIN_RIGHT = 20 +const MARGIN_BOTTOM = 60 // au-dessus de BOTTOM_MARGIN (barre Windows) + marge de confort + +// Hauteur réservée au bouton de pioche + son espacement avec la main. +const DRAW_BUTTON_H = 30 +const GAP_BUTTON_HAND = 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. + */ +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 + 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 + * 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 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 + // vers la gauche. + const rightEdge = canvasWidth - MARGIN_RIGHT + const lastCardCenterX = rightEdge - cardW / 2 + const startX = lastCardCenterX - Math.max(cardCount - 1, 0) * spacing + + return { startX, y, spacing, scale: EVENT_CARD_SCALE } +} + +/** Position (x, y) de la carte 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/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index 71976f4..dbb6371 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -3,6 +3,7 @@ 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 { ITEMS, getItemById } from '../items' import { BUILDINGS, getBuildingById } from '../buildings' import { EVENTS, getEventById, DRAW_COST } from '../events' @@ -156,9 +157,13 @@ export class GameScene extends Phaser.Scene { this.playSlots = [] this.slotObjects = [] // pour pouvoir les détruire/recréer - // Légende attributs (coin en bas à droite, au-dessus de la marge Windows) + // Légende attributs, coin en bas à GAUCHE (déplacée depuis la droite : + // le coin bas-droit est désormais occupé par la zone "cartes Événement" + // — bouton de pioche + main, voir eventHandLayout.js). Le bas-gauche + // reste libre sous la colonne de la boutique d'objets (qui s'arrête + // bien avant le bas de l'écran, voir _itemTokensStartY/_itemTokensSpacing). this.legendObjects = [] - this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10) + this._buildAttrLegend(10, height - BOTTOM_MARGIN - 10) } /** @@ -186,8 +191,8 @@ export class GameScene extends Phaser.Scene { if (this.turnText) this.turnText.setPosition(width / 2, 18) if (this.phaseText) this.phaseText.setPosition(width / 2, 40) - // Légende attributs - this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10) + // Légende attributs (bas-gauche, voir _buildTable) + this._buildAttrLegend(10, height - BOTTOM_MARGIN - 10) // Slots de jeu. Reconstruire les slots est destructif et invaliderait les // coordonnées déjà utilisées par les cartes révélées à l'écran (phase @@ -303,7 +308,7 @@ export class GameScene extends Phaser.Scene { fontSize: '10px', fontFamily: 'Georgia', color: '#6a5040' - }).setOrigin(1, 1) + }).setOrigin(0, 1) // ancré à GAUCHE de (x, y) — légende désormais en bas-gauche ) }) } @@ -649,10 +654,18 @@ export class GameScene extends Phaser.Scene { // ─── Cartes Événement (pioche + seconde main + ciblage) ──────────────────── - /** Position du bouton de pioche d'événement : à droite du bouton de confirmation, même Y. */ + /** + * Position du bouton de pioche d'événement : bas-DROITE du canvas, au + * dessus de la main de cartes événement. Calcul délégué à + * eventHandLayout.js (module pur, testé unitairement) pour rester + * clairement séparé du bouton "CONFIRMER MA CARTE" (centré en bas) — un + * placement précédent (à droite du bouton de confirmation, même Y) se + * chevauchait physiquement avec lui (280px de large, bords qui se + * recouvraient de ~25px), prêtant à confusion entre les deux actions. + */ _eventDrawButtonPos() { - const { width } = this.scale - return { x: width / 2 + 210, y: this._playButtonY() } + const { width, height } = this.scale + return eventDrawButtonPosition(width, height) } _buildEventDrawButton() { @@ -718,16 +731,12 @@ export class GameScene extends Phaser.Scene { this.socket.emit('buy-event-draw', { roomId: this.roomId }) } - /** Position/espacement de ma main de cartes événement (max 2), au-dessus de mon bouton de pioche. */ - _eventHandGeometry() { - const { x } = this._eventDrawButtonPos() - const y = this._playButtonY() - 100 - return { x, y, spacing: 90 } - } - /** - * (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), à - * l'échelle réduite pour rester compacte au-dessus du bouton de pioche. + * (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. */ _buildEventHand(eventIds) { this.eventCards.forEach(c => c.destroy()) @@ -736,14 +745,14 @@ export class GameScene extends Phaser.Scene { if (!eventIds || eventIds.length === 0) return - const { x, y, spacing } = this._eventHandGeometry() - const startX = x - (eventIds.length - 1) * spacing / 2 + const { width, height } = this.scale + const { startX, y, spacing, scale } = 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(0.55) + card.setScale(scale) card.on('select', this._onEventCardSelected, this) this.eventCards.push(card) }) @@ -751,8 +760,8 @@ export class GameScene extends Phaser.Scene { _layoutEventHand() { if (this.eventCards.length === 0) return - const { x, y, spacing } = this._eventHandGeometry() - const startX = x - (this.eventCards.length - 1) * spacing / 2 + const { width, height } = this.scale + const { startX, y, spacing } = eventHandGeometry(width, height, this.eventCards.length) this.eventCards.forEach((card, i) => { card.setPosition(startX + i * spacing, y) card._baseY = y diff --git a/client/test/unit/eventHandLayout.test.js b/client/test/unit/eventHandLayout.test.js new file mode 100644 index 0000000..0432f7a --- /dev/null +++ b/client/test/unit/eventHandLayout.test.js @@ -0,0 +1,154 @@ +import { describe, it, expect } from 'vitest' +import { + eventDrawButtonPosition, + eventHandGeometry, + eventCardPosition, + EVENT_CARD_SCALE +} from '../../src/game/objects/eventHandLayout' +import { CARD_W, CARD_H } from '../../src/game/objects/cardLayout' + +// Dimensions de canvas typiques utilisées dans les tests (cohérentes avec +// GameFactory.js : width = innerWidth, height = innerHeight - 60). +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) + expect(x).toBeGreaterThan(CANVAS_W / 2) + expect(y).toBeGreaterThan(CANVAS_H / 2) + }) + + it('reste entièrement à l\'intérieur du canvas (ne déborde jamais à droite ni en bas)', () => { + const buttonWidth = 190 + const buttonHeight = 30 + const { x, y } = eventDrawButtonPosition(CANVAS_W, CANVAS_H, buttonWidth) + expect(x + buttonWidth / 2).toBeLessThanOrEqual(CANVAS_W) + expect(x - buttonWidth / 2).toBeGreaterThanOrEqual(0) + expect(y + buttonHeight / 2).toBeLessThanOrEqual(CANVAS_H) + }) + + it('est positionné strictement au-dessus de la main de cartes é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 + expect(buttonBottom).toBeLessThanOrEqual(handTop) + }) + + it('reste bien à droite du bouton "CONFIRMER MA CARTE" (centré, ~280px de large) — pas de chevauchement', () => { + const confirmButtonWidth = 280 + const confirmButtonRightEdge = CANVAS_W / 2 + confirmButtonWidth / 2 + const drawButtonWidth = 190 + const { x } = eventDrawButtonPosition(CANVAS_W, CANVAS_H, drawButtonWidth) + const drawButtonLeftEdge = x - drawButtonWidth / 2 + // Régression du bug signalé : l'ancien placement (x = width/2 + 210, + // même Y que le bouton confirmer) chevauchait physiquement son bord + // droit de ~25px. Le nouveau placement bas-droite doit être + // verticalement séparé (voir test précédent) ET ne pas non plus se + // chevaucher horizontalement s'ils finissaient à la même hauteur. + expect(drawButtonLeftEdge).toBeGreaterThanOrEqual(confirmButtonRightEdge) + }) + + it('accepte une largeur de bouton personnalisée', () => { + const narrow = eventDrawButtonPosition(CANVAS_W, CANVAS_H, 100) + const wide = eventDrawButtonPosition(CANVAS_W, CANVAS_H, 300) + // Un bouton plus large doit être centré plus loin du bord droit + expect(narrow.x).toBeGreaterThan(wide.x) + }) + }) + + describe('eventHandGeometry', () => { + it('positionne la main dans la moitié basse du canvas', () => { + const { y } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + 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('avec 1 carte, elle est ancrée 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) + expect(startX).toBeCloseTo(startXTwo + spacing, 5) + }) + + it('gère cardCount=0 sans produire de position invalide (NaN/Infinity)', () => { + const { startX, y, spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 0) + expect(Number.isFinite(startX)).toBe(true) + expect(Number.isFinite(y)).toBe(true) + 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('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('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) + }) + }) + + describe('eventCardPosition', () => { + it('retourne la même position que eventHandGeometry pour un index donné', () => { + const { startX, y, spacing } = eventHandGeometry(CANVAS_W, CANVAS_H, 2) + const pos0 = eventCardPosition(CANVAS_W, CANVAS_H, 2, 0) + const pos1 = eventCardPosition(CANVAS_W, CANVAS_H, 2, 1) + expect(pos0).toEqual({ x: startX, y }) + expect(pos1).toEqual({ x: startX + spacing, y }) + }) + + it('les positions de 2 cartes 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) + }) + }) + + 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 + // 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) + }) + }) +})