diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js index 64a17d2..a579d7d 100644 --- a/client/src/game/objects/ItemBoardToken.js +++ b/client/src/game/objects/ItemBoardToken.js @@ -1,5 +1,5 @@ import Phaser from 'phaser' -import { ICON_SIZE } from './tokenLayout' +import { ICON_SIZE, EXPANDED_W, EXPANDED_H, BUY_BTN_H, EXPANDED_H_WITH_BTN, expandedCardOffset } from './tokenLayout' const ATTR_COLORS = { food: 0xf0c030, @@ -15,25 +15,18 @@ const ATTR_LABELS = { diplo: 'Diplomatie' } -// 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). +// Re-export pour compatibilité : ces constantes/fonctions de géométrie sont +// désormais définies dans tokenLayout.js (module pur, sans dépendance +// Phaser) afin que les modules de calcul (ex: eventHandLayout.js) et leurs +// tests Vitest puissent les importer sans jamais charger Phaser — +// nécessaire car Phaser échoue à l'import en environnement jsdom (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 -// lisible même pour les mini-jetons (mes objets / ceux des adversaires), -// qui utilisent un displayScale réduit pour le jeton compact SANS que cela -// ne réduise la taille réelle de la carte détaillée (voir _showExpanded). -const EXPANDED_W = 190 -const EXPANDED_H = 230 +// Hauteur de la zone image dans la carte détaillée (reste local : ne +// dépend d'aucun calcul de positionnement externe, contrairement aux +// constantes ci-dessus). const EXPANDED_IMAGE_H = 110 -const BUY_BTN_H = 26 - -// La carte agrandie s'allonge pour laisser la place au bouton ACHETER -const EXPANDED_H_WITH_BTN = EXPANDED_H + BUY_BTN_H + 10 // Délai avant de refermer la carte agrandie, pour tolérer le passage de la // souris entre le jeton compact et la carte agrandie sans clignotement. @@ -85,15 +78,24 @@ const HOVER_SHOW_DELAY = 80 * é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. + * vAnchor: 'bottom' (défaut) ou 'top'. Détermine de quel côté du jeton + * compact la carte détaillée s'étend verticalement au survol. 'bottom' = + * la carte s'étend vers le BAS (jeton ancré sur son coin haut) — adapté + * aux boutiques d'objets/bâtiments, positionnées en haut du canvas. + * 'top' = la carte s'étend vers le HAUT (jeton ancré sur son coin bas) — + * nécessaire pour la main de cartes Événement (positionnée en BAS du + * canvas), sinon la carte déborderait sous le bas du canvas et resterait + * invisible/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') { + constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left', vAnchor = 'bottom') { super(scene, x, y) this.itemData = itemData this.status = status this.affordable = affordable this.kind = kind this.hAnchor = hAnchor + this.vAnchor = vAnchor this._displayScale = displayScale this._expanded = null this._hideTimer = null @@ -275,13 +277,15 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { * displayScale du jeton d'où elle a été ouverte (jeton boutique à taille * normale, ou mini-jeton "posé sur la table" pour moi/les adversaires). * - * Elle est ancrée sur un coin (haut-gauche ou haut-droit selon hAnchor) du - * jeton compact (dont la taille visuelle dépend de displayScale) plutôt - * que centrée dessus, pour rester dans les limites du canvas quel que - * soit l'emplacement du jeton : elle s'étend toujours vers le bas, et - * horizontalement à l'opposé du bord de canvas le plus proche (vers la - * droite pour la boutique d'objets à gauche, vers la gauche pour la - * boutique de bâtiments à droite — voir hAnchor). + * Elle est ancrée sur un coin du jeton compact (dont la taille visuelle + * dépend de displayScale) plutôt que centrée dessus, pour rester dans les + * limites du canvas quel que soit l'emplacement du jeton : horizontalement + * à l'opposé du bord de canvas le plus proche (vers la droite pour la + * boutique d'objets à gauche, vers la gauche pour la boutique de + * bâtiments à droite — voir hAnchor), et verticalement vers le bas par + * défaut ('bottom', pour les jetons en haut du canvas) ou vers le haut + * ('top', pour la main d'événements en bas du canvas — sinon la carte + * déborderait sous le bas du canvas, invisible/injoignable au clic). */ _showExpanded() { if (this._expanded) return @@ -293,13 +297,13 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { const buildingExtraH = this.kind === 'building' ? 18 : 0 const cardH = (isAvailable ? EXPANDED_H_WITH_BTN : EXPANDED_H) + buildingExtraH - // Demi-taille visuelle réelle du jeton compact (après displayScale) : - // sert à aligner le coin du jeton (gauche ou droit selon hAnchor) sur le - // coin correspondant de la carte détaillée, tel qu'il apparaît réellement - // à l'écran. - const iconHalf = (ICON_SIZE * this._displayScale) / 2 - const dx = (this.hAnchor === 'right' ? -1 : 1) * (EXPANDED_W / 2 - iconHalf) - const dy = cardH / 2 - iconHalf + // Décalage (dx, dy) du centre de la carte par rapport au centre du + // jeton compact — calcul délégué à tokenLayout.js (fonction pure, + // couverte par des tests unitaires) pour garantir que le jeton (dont la + // taille visuelle dépend de displayScale) reste toujours aligné avec le + // coin correspondant de la carte détaillée, tel qu'il apparaît + // réellement à l'écran. + const { dx, dy } = expandedCardOffset(cardH, this._displayScale, this.hAnchor, this.vAnchor) const attrColor = this._attrColor() const c = this.scene.add.container(dx, dy) diff --git a/client/src/game/objects/tokenLayout.js b/client/src/game/objects/tokenLayout.js index f3d2ab5..c506f15 100644 --- a/client/src/game/objects/tokenLayout.js +++ b/client/src/game/objects/tokenLayout.js @@ -1,10 +1,44 @@ /** - * 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). + * Constantes et calculs de géométrie purs pour ItemBoardToken (objets, + * bâtiments, cartes Événement), extraits dans leur propre module SANS + * dépendance à Phaser (contrairement à ItemBoardToken.js qui importe + * `phaser`), pour pouvoir être importés par des modules de calcul de + * géométrie (ex: eventHandLayout.js) et testés 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). */ + +// Taille du jeton compact affiché en permanence sur le plateau (avant mise +// à l'échelle éventuelle via displayScale). export const ICON_SIZE = 46 + +// Taille de la carte détaillée affichée au survol (voir ItemBoardToken._showExpanded). +export const EXPANDED_W = 190 +export const EXPANDED_H = 230 +export const BUY_BTN_H = 26 + +// La carte agrandie s'allonge pour laisser la place au bouton ACHETER/JOUER. +export const EXPANDED_H_WITH_BTN = EXPANDED_H + BUY_BTN_H + 10 + +/** + * Décalage (dx, dy) du centre de la carte détaillée par rapport au centre + * du jeton compact, selon hAnchor/vAnchor. Fonction pure extraite de + * ItemBoardToken._showExpanded pour être testée unitairement — notamment la + * régression corrigée : avec vAnchor='bottom' (comportement par défaut, + * adapté aux jetons en haut du canvas), la carte s'étend TOUJOURS vers le + * bas, ce qui la faisait déborder sous le canvas pour les jetons déjà + * positionnés en bas d'écran (main de cartes Événement) — d'où l'ajout de + * vAnchor='top' pour ces jetons-là. + * + * @param {number} cardH hauteur réelle de la carte détaillée (dépend de + * isAvailable/kind, voir EXPANDED_H / EXPANDED_H_WITH_BTN) + * @param {number} displayScale échelle du jeton compact (0 < scale <= 1) + * @param {'left'|'right'} hAnchor + * @param {'bottom'|'top'} vAnchor + */ +export function expandedCardOffset(cardH, displayScale, hAnchor = 'left', vAnchor = 'bottom') { + const iconHalf = (ICON_SIZE * displayScale) / 2 + const dx = (hAnchor === 'right' ? -1 : 1) * (EXPANDED_W / 2 - iconHalf) + const dy = (vAnchor === 'top' ? -1 : 1) * (cardH / 2 - iconHalf) + return { dx, dy } +} diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index 62ac8b7..a3aeff2 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -764,7 +764,10 @@ export class GameScene extends Phaser.Scene { // 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') + // vAnchor='top' : la main est aussi ancrée en BAS du canvas — sans ce + // paramètre, la carte détaillée s'étend vers le bas par défaut et + // déborde largement sous le canvas, la rendant invisible/injoignable. + const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', true, 1, 'event', 'right', 'top') token.on('play', (data) => this._onEventCardPlay(token, data)) this.eventCards.push(token) }) @@ -799,7 +802,7 @@ export class GameScene extends Phaser.Scene { 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') + const token = new ItemBoardToken(this, startX + i * spacing, y, eventData, 'available', false, 1, 'event', 'right', 'top') token.on('play', (data) => this._onEventCardPlay(token, data)) return token }) diff --git a/client/test/unit/tokenLayout.test.js b/client/test/unit/tokenLayout.test.js new file mode 100644 index 0000000..29ba36a --- /dev/null +++ b/client/test/unit/tokenLayout.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import { + ICON_SIZE, + EXPANDED_W, + EXPANDED_H, + BUY_BTN_H, + EXPANDED_H_WITH_BTN, + expandedCardOffset +} from '../../src/game/objects/tokenLayout' + +describe('game/objects/tokenLayout', () => { + describe('constantes', () => { + it('EXPANDED_H_WITH_BTN est plus grand que EXPANDED_H (place pour le bouton)', () => { + expect(EXPANDED_H_WITH_BTN).toBeGreaterThan(EXPANDED_H) + expect(EXPANDED_H_WITH_BTN).toBe(EXPANDED_H + BUY_BTN_H + 10) + }) + }) + + describe('expandedCardOffset', () => { + const cardH = EXPANDED_H_WITH_BTN + + it('hAnchor="left" (défaut) : la carte s\'étend vers la DROITE (dx positif)', () => { + const { dx } = expandedCardOffset(cardH, 1, 'left', 'bottom') + expect(dx).toBeGreaterThan(0) + }) + + it('hAnchor="right" : la carte s\'étend vers la GAUCHE (dx négatif)', () => { + const { dx } = expandedCardOffset(cardH, 1, 'right', 'bottom') + expect(dx).toBeLessThan(0) + }) + + it('vAnchor="bottom" (défaut) : la carte s\'étend vers le BAS (dy positif)', () => { + const { dy } = expandedCardOffset(cardH, 1, 'left', 'bottom') + expect(dy).toBeGreaterThan(0) + }) + + it('vAnchor="top" : la carte s\'étend vers le HAUT (dy négatif) — régression corrigée', () => { + // Bug corrigé : la main de cartes Événement (positionnée en bas du + // canvas) utilisait implicitement vAnchor="bottom" (comportement par + // défaut, pensé pour les jetons en haut du canvas comme la boutique + // d'objets), faisant déborder la carte détaillée sous le bas du + // canvas — invisible et injoignable au clic (bouton JOUER inatteignable). + const { dy } = expandedCardOffset(cardH, 1, 'left', 'top') + expect(dy).toBeLessThan(0) + }) + + it('dx et dy ont la même magnitude quel que soit le signe de l\'ancrage (symétrie)', () => { + const left = expandedCardOffset(cardH, 1, 'left', 'bottom') + const right = expandedCardOffset(cardH, 1, 'right', 'bottom') + expect(Math.abs(left.dx)).toBeCloseTo(Math.abs(right.dx), 10) + + const bottom = expandedCardOffset(cardH, 1, 'left', 'bottom') + const top = expandedCardOffset(cardH, 1, 'left', 'top') + expect(Math.abs(bottom.dy)).toBeCloseTo(Math.abs(top.dy), 10) + }) + + it('un displayScale réduit rapproche le décalage de 0 (jeton plus petit, moins de compensation nécessaire)', () => { + const fullScale = expandedCardOffset(cardH, 1, 'left', 'bottom') + const halfScale = expandedCardOffset(cardH, 0.5, 'left', 'bottom') + expect(halfScale.dx).toBeGreaterThan(fullScale.dx) + expect(halfScale.dy).toBeGreaterThan(fullScale.dy) + }) + + it('avec displayScale=1, le coin du jeton (ICON_SIZE/2) s\'aligne exactement sur le coin de la carte', () => { + // Vérifie la propriété fondamentale de ce calcul : le bord du jeton + // compact doit coïncider avec le bord correspondant de la carte + // détaillée, sans espace ni chevauchement. + const { dx, dy } = expandedCardOffset(cardH, 1, 'left', 'bottom') + const iconHalf = ICON_SIZE / 2 + // hAnchor='left' : le bord gauche du jeton (-iconHalf, relatif au + // centre du jeton) coïncide avec le bord gauche de la carte + // (dx - EXPANDED_W/2, puisque le container carte est positionné à (dx, dy)). + expect(-iconHalf).toBeCloseTo(dx - EXPANDED_W / 2, 10) + // vAnchor='bottom' : le bord HAUT du jeton (-iconHalf) coïncide avec + // le bord HAUT de la carte (dy - cardH/2) — la carte s'étend vers le + // BAS à partir du haut du jeton. + expect(-iconHalf).toBeCloseTo(dy - cardH / 2, 10) + }) + }) + + describe('non-régression : carte de la main d\'événements reste dans le canvas (vAnchor="top")', () => { + it('pour un jeton positionné en bas du canvas, la carte détaillée (vAnchor="top") ne déborde jamais sous le canvas', () => { + const canvasH = 1080 + const marginBottom = 60 + const tokenY = canvasH - marginBottom - ICON_SIZE / 2 // même formule que eventHandGeometry + + const { dy } = expandedCardOffset(EXPANDED_H_WITH_BTN, 1, 'right', 'top') + const cardCenterY = tokenY + dy + const cardBottom = cardCenterY + EXPANDED_H_WITH_BTN / 2 + const cardTop = cardCenterY - EXPANDED_H_WITH_BTN / 2 + + expect(cardBottom).toBeLessThanOrEqual(canvasH) + expect(cardTop).toBeGreaterThanOrEqual(0) + }) + + it('avec l\'ancien comportement implicite (vAnchor="bottom"), la carte aurait débordé — confirme la nécessité du fix', () => { + const canvasH = 1080 + const marginBottom = 60 + const tokenY = canvasH - marginBottom - ICON_SIZE / 2 + + const { dy } = expandedCardOffset(EXPANDED_H_WITH_BTN, 1, 'right', 'bottom') + const cardCenterY = tokenY + dy + const cardBottom = cardCenterY + EXPANDED_H_WITH_BTN / 2 + + expect(cardBottom).toBeGreaterThan(canvasH) // déborde bien, confirmant le bug d'origine + }) + + it('sur une petite résolution (720p), la carte (vAnchor="top") reste aussi dans les limites', () => { + const canvasH = 720 + const marginBottom = 60 + const tokenY = canvasH - marginBottom - ICON_SIZE / 2 + + const { dy } = expandedCardOffset(EXPANDED_H_WITH_BTN, 1, 'right', 'top') + const cardCenterY = tokenY + dy + const cardBottom = cardCenterY + EXPANDED_H_WITH_BTN / 2 + const cardTop = cardCenterY - EXPANDED_H_WITH_BTN / 2 + + expect(cardBottom).toBeLessThanOrEqual(canvasH) + expect(cardTop).toBeGreaterThanOrEqual(0) + }) + }) +})