feat: distinction visuelle "achat en attente" avant résolution du tour
Après avoir cliqué sur ACHETER, aucune indication ne montrait que l'achat était pris en compte avant que la mini-carte définitive n'apparaisse à côté de la main (à la résolution du tour, plusieurs secondes plus tard). Le serveur exposait déjà pendingBuyItemId/pendingBuyBuildingId via getStateFor(), mais rien côté client ne les utilisait. - ItemBoardToken.js : nouveau paramètre `pending`. Quand actif, le jeton compact affiche un halo doré pulsant (tween alpha 1<->0.25 en boucle) et un texte "En attente..." sous l'icône, tant que l'achat n'est pas résolu. Le bouton ACHETER de la carte détaillée passe alors sur un libellé distinct "EN ATTENTE..." (au lieu de "INSUFFISANT", qui suggérerait à tort un problème de jauge) et devient non-cliquable, cohérent avec le refus déjà appliqué côté serveur pour une seconde tentative le même tour. Le tween est proprement arrêté dans destroy() (les jetons sont détruits/ recréés à chaque game-state). - GameScene.js : _buildItemTokens()/_buildBuildingTokens() déterminent `pending` à partir de state.pendingBuyItemId/pendingBuyBuildingId (déjà reçus du serveur, jamais consommés auparavant) et le passent au jeton correspondant, désactivant aussi son affordabilité pendant l'attente. shopChanged/buildingShopChanged comparent désormais aussi ces deux champs pour déclencher la reconstruction dès le clic sur ACHETER (avant, seul un changement de myItems/availableItems/jauges provoquait un rebuild). - Nouveau test E2E (item-shop.spec.js) : après achat, vérifie que le jeton correspondant a pending=true, affordable=false et un tween actif chez l'acheteur, que l'ADVERSAIRE ne voit PAS cet état "pending" (propre au joueur), et qu'après résolution du tour le jeton disparaît normalement de la boutique (comportement de stock unique déjà existant, inchangé). 54 tests unitaires client + 166 tests unitaires serveur + suite item-shop.spec.js (4 tests) au vert.
This commit is contained in:
1 parent
a5ecc23498
commit
29cc728f09
3 files changed
+127
-6
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user