fix: la carte détaillée d'un événement débordait sous le canvas au survol
ItemBoardToken._showExpanded() ancrait TOUJOURS la carte détaillée vers le
BAS du jeton compact, quel que soit son emplacement sur le plateau —
adapté aux boutiques d'objets/bâtiments (positionnées en haut du canvas),
mais pas à la main de cartes Événement (positionnée en BAS du canvas,
voir eventHandLayout.js) : la carte (jusqu'à 266px de haut avec le bouton
JOUER) débordait alors largement sous le bas de l'écran, la rendant
invisible et son bouton totalement injoignable au clic.
- Ajout du paramètre vAnchor ('bottom' par défaut, 'top' pour les jetons
proches du bas de l'écran) sur ItemBoardToken, symétrique au hAnchor déjà
existant pour l'axe horizontal. La main d'événements passe désormais
vAnchor='top' pour que la carte s'étende vers le HAUT au lieu du bas.
- Extraction du calcul de décalage (expandedCardOffset) et des constantes
de dimensionnement (EXPANDED_W/H, BUY_BTN_H) dans tokenLayout.js (module
pur, sans dépendance Phaser) pour pouvoir le couvrir par de vrais tests
unitaires Vitest.
- 11 nouveaux tests unitaires (tokenLayout.test.js) : orientation dx/dy
selon hAnchor/vAnchor, alignement exact du bord du jeton avec le bord de
la carte, et surtout la régression elle-même — un test vérifie que
vAnchor='top' maintient la carte dans les limites du canvas pour un
jeton en bas d'écran (1080p et 720p), et qu'un test symétrique confirme
que l'ancien comportement implicite (vAnchor='bottom') débordait bien
sous le canvas dans ce même scénario.
- 54 tests unitaires client au vert au total (43 précédents + 11 nouveaux),
vérification privilégiée par rapport à une nouvelle suite E2E (lente vu
la pioche aléatoire à stabiliser dans ce projet).
This commit is contained in:
1 parent
1a91a0f876
commit
33a3c20dcb
4 files changed
+204
-41
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user