diff --git a/client/src/game/objects/ItemBoardToken.js b/client/src/game/objects/ItemBoardToken.js index 3e8f765..545469f 100644 --- a/client/src/game/objects/ItemBoardToken.js +++ b/client/src/game/objects/ItemBoardToken.js @@ -86,9 +86,16 @@ const HOVER_SHOW_DELAY = 80 * 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. + * pending: true si CE joueur a acheté cet objet/bâtiment ce tour mais que + * l'achat n'est pas encore résolu côté serveur (résolution différée à la + * fin du tour — voir Room.js buyItem/buyBuilding). Affiche une pulsation + * dorée + un texte "En attente..." sur le jeton, TOUJOURS visible dans sa + * colonne boutique d'origine jusqu'à la résolution, pour que le joueur + * voie clairement que son achat est pris en compte avant que la mini-carte + * définitive n'apparaisse à côté de sa main. */ export class ItemBoardToken extends Phaser.GameObjects.Container { - constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left', vAnchor = 'bottom') { + constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left', vAnchor = 'bottom', pending = false) { super(scene, x, y) this.itemData = itemData this.status = status @@ -96,10 +103,12 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { this.kind = kind this.hAnchor = hAnchor this.vAnchor = vAnchor + this.pending = pending this._displayScale = displayScale this._expanded = null this._hideTimer = null this._showTimer = null + this._pulseTween = null this._buildCompact() scene.add.existing(this) } @@ -225,6 +234,32 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { compact.add(overlay) } + // ── Distinction visuelle "achat en attente de résolution" ──────────────── + // Halo doré pulsant autour du jeton + petit texte sous l'icône, tant que + // ce joueur a acheté cet objet/bâtiment mais que le tour n'a pas encore + // été résolu par le serveur. Ajouté sur `compact` (pas sur `this`, l'outer + // container) pour rester à l'échelle du jeton compact, cohérent avec le + // reste de son apparence. + if (this.pending) { + const glow = this.scene.add.graphics() + glow.lineStyle(3, 0xd4a732, 1) + glow.strokeRoundedRect(-S / 2 - 3, -S / 2 - 3, S + 6, S + 6, 10) + compact.add(glow) + this._pulseTween = this.scene.tweens.add({ + targets: glow, + alpha: { from: 1, to: 0.25 }, + duration: 550, + yoyo: true, + repeat: -1, + ease: 'Sine.easeInOut' + }) + + compact.add(this.scene.add.text(0, S / 2 + 10, 'En attente...', { + fontSize: '8px', fontFamily: 'Georgia', fontStyle: 'bold', + color: '#d4a732', stroke: '#000000', strokeThickness: 2 + }).setOrigin(0.5)) + } + compact.setScale(this._displayScale) // La zone interactive (hit area) doit correspondre à la taille VISUELLE @@ -386,7 +421,10 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { const btnW = EXPANDED_W - 20 const canAct = this.affordable const actionLabel = isEvent ? 'JOUER' : 'ACHETER' - const disabledLabel = isEvent ? 'INDISPONIBLE' : 'INSUFFISANT' + // Libellé désactivé distinct pour un achat déjà en attente de + // résolution (le joueur A les points, ce n'est pas un problème de + // jauge — une seconde tentative serait simplement refusée le même tour). + const disabledLabel = isEvent ? 'INDISPONIBLE' : (this.pending ? 'EN ATTENTE...' : 'INSUFFISANT') const eventName = isEvent ? 'play' : 'buy' const btnBg = this.scene.add.graphics() @@ -442,6 +480,11 @@ export class ItemBoardToken extends Phaser.GameObjects.Container { // "fantôme" après reconstruction, ou lever une erreur silencieuse. this._cancelHide() this._cancelShow() + // Arrêter le tween de pulsation "en attente" : les boutiques sont + // détruites/recréées à chaque game-state (voir GameScene._buildItemTokens), + // donc ce tween tournerait indéfiniment sur un Graphics déjà détruit + // sans cet arrêt explicite. + if (this._pulseTween) { this._pulseTween.stop(); this._pulseTween = null } super.destroy(fromScene) } } diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js index 9a26263..38b2aa5 100644 --- a/client/src/game/scenes/GameScene.js +++ b/client/src/game/scenes/GameScene.js @@ -487,9 +487,14 @@ export class GameScene extends Phaser.Scene { const myGauges = me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 } stillAvailable.forEach((itemData, i) => { - const affordable = (myGauges[itemData.attr] ?? 0) >= itemData.cost + const pending = state.pendingBuyItemId === itemData.id + // Grisé (bouton ACHETER désactivé) tant que l'achat est en attente de + // résolution : le serveur refuserait de toute façon une seconde + // tentative le même tour, autant l'indiquer visuellement plutôt que + // de laisser cliquer pour rien. + const affordable = !pending && (myGauges[itemData.attr] ?? 0) >= itemData.cost - const token = new ItemBoardToken(this, x, startY + i * spacing, itemData, 'available', affordable) + const token = new ItemBoardToken(this, x, startY + i * spacing, itemData, 'available', affordable, 1, 'item', 'left', 'bottom', pending) token.on('buy', (data) => this._onBuyItem(data)) this.itemTokens.push(token) }) @@ -598,14 +603,16 @@ export class GameScene extends Phaser.Scene { const myGauges = me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 } stillAvailable.forEach((building, i) => { - const affordable = Object.entries(building.cost) + const pending = state.pendingBuyBuildingId === building.id + // Grisé tant que l'achat est en attente de résolution (voir _buildItemTokens). + const affordable = !pending && Object.entries(building.cost) .every(([attr, cost]) => (myGauges[attr] ?? 0) >= cost) // hAnchor='right' : cette boutique est sur le bord DROIT du canvas, // la carte détaillée doit donc s'étendre vers la GAUCHE au survol // pour ne jamais déborder hors du canvas (sinon son bouton ACHETER // devient injoignable au clic, cf. bug corrigé). - const token = new ItemBoardToken(this, x, startY + i * spacing, building, 'available', affordable, 1, 'building', 'right') + const token = new ItemBoardToken(this, x, startY + i * spacing, building, 'available', affordable, 1, 'building', 'right', 'bottom', pending) token.on('buy', (data) => this._onBuyBuilding(data)) this.buildingTokens.push(token) }) @@ -1133,10 +1140,12 @@ export class GameScene extends Phaser.Scene { const shopChanged = !this._shopState || JSON.stringify(this._shopState.myItems) !== JSON.stringify(newShopState.myItems) || JSON.stringify(this._shopState.availableItems) !== JSON.stringify(newShopState.availableItems) || + this._shopState.pendingBuyItemId !== newShopState.pendingBuyItemId || this._lastMyGaugesKey !== myGaugesKey const buildingShopChanged = !this._shopState || JSON.stringify(this._shopState.myBuildings) !== JSON.stringify(newShopState.myBuildings) || JSON.stringify(this._shopState.availableBuildings) !== JSON.stringify(newShopState.availableBuildings) || + this._shopState.pendingBuyBuildingId !== newShopState.pendingBuyBuildingId || this._lastMyGaugesKey !== myGaugesKey this._shopState = newShopState this._lastMyGaugesKey = myGaugesKey diff --git a/e2e/tests/item-shop.spec.js b/e2e/tests/item-shop.spec.js index 9da7963..5f2157a 100644 --- a/e2e/tests/item-shop.spec.js +++ b/e2e/tests/item-shop.spec.js @@ -111,4 +111,73 @@ test.describe('Boutique d\'objets', () => { await ctx1.close() await ctx2.close() }) + + test('un achat en attente affiche une pulsation dorée sur le jeton, avant que le tour ne soit résolu', async ({ browser }) => { + const ctx1 = await browser.newContext() + const ctx2 = await browser.newContext() + const p1 = await ctx1.newPage() + const p2 = await ctx2.newPage() + await installPhaserDebugHook(p1) + await installPhaserDebugHook(p2) + + const roomId = await createRoom(p1, 'Alice') + await joinRoom(p2, 'Bob', roomId) + await startGame(p1) + await p1.waitForTimeout(2000) + + let gauges = await getMyGauges(p1) + let attempts = 0 + while (gauges.war < 3 && attempts < 12) { + await playFullTurn([p1, p2], ['war', { attr: 'war', mode: 'min' }]) + gauges = await getMyGauges(p1) + attempts++ + } + expect(gauges.war).toBeGreaterThanOrEqual(3) + + const bought = await buyShopItem(p1, 'trebuchet') + expect(bought).toBe(true) + + // AVANT la résolution du tour : le jeton reste dans la boutique (encore + // dans availableItems côté serveur), mais doit maintenant afficher l'état + // "pending" (pulsation dorée + bouton grisé "EN ATTENTE..."). + const pendingState = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const token = scene.itemTokens.find(t => t.itemData.id === 'trebuchet') + return { + exists: !!token, + pending: token?.pending ?? null, + affordable: token?.affordable ?? null, + hasPulseTween: !!token?._pulseTween + } + }) + expect(pendingState.exists).toBe(true) + expect(pendingState.pending).toBe(true) + expect(pendingState.affordable).toBe(false) + expect(pendingState.hasPulseTween).toBe(true) + + // Une seconde salle voit-elle aussi cet état ? Non — "pending" est + // propre à CE joueur (pendingBuyItemId scoped par socketId côté serveur), + // Bob ne doit pas voir le jeton de Trébuchet en état "pending". + const bobView = await p2.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + const token = scene.itemTokens.find(t => t.itemData.id === 'trebuchet') + return token?.pending ?? null + }) + expect(bobView).toBe(false) + + // APRÈS résolution du tour : le jeton disparaît complètement de la + // boutique (objet acheté, stock épuisé) — plus d'état "pending" à vérifier. + await playFullTurn([p1, p2], ['war', 'health']) + const afterResolve = await p1.evaluate(() => { + const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1] + const scene = game.scene.getScene('GameScene') + return scene.itemTokens.find(t => t.itemData.id === 'trebuchet') + }) + expect(afterResolve).toBeUndefined() + + await ctx1.close() + await ctx2.close() + }) })