feat: système d'objets achetables (boutique + effets permanents) et refonte visuelle

- Nouveaux personnages illustrés (12 PNG remplacés dans client/public/cards)
- 4 objets achetables (Trébuchet, Charrue, Trousse du Médecin, Sceau Royal)
  avec effets permanents (double_gain, guaranteed_gain, copy_best_in_game),
  stock unique par partie, priorité d'achat tournante entre joueurs
- Boutique en colonne à gauche du plateau : icônes compactes, carte détaillée
  agrandie au survol (taille découplée du displayScale du jeton), bouton
  ACHETER actif/grisé selon la jauge du joueur, retrait immédiat de la
  colonne une fois l'objet acheté (les autres remontent pour combler)
- Objets achetés affichés "posés sur la table" : à côté de la main pour soi,
  sous le slot de jeu pour les adversaires
- Icône du premier joueur (priorité d'achat) dans le HUD Vue
- Refonte des cartes personnage (CardObject) : portrait plein cadre épuré,
  valeurs d'attribut discrètes aux 4 coins
- Fix removePlayer : nettoyage de pendingBuys/pendingPlays et recalage de
  firstPlayerIndex à la déconnexion d'un joueur
- 77 tests unitaires serveur (node:test) : cards, items, Room, RoomManager
- README mis à jour avec les règles complètes du jeu
- .gitignore : exclusion de .idea/ et .serena/ (artefacts d'outils locaux)
This commit is contained in:
Kevin L'Hereec committed 2026-08-21 14:53:58 +02:00
1 parent da06ecaf2d
commit 6a1c800253
37 files changed
+3558 -188

No files matched your search

+73 -5
View File
@@ -1,4 +1,7 @@
import Phaser from 'phaser'
import { CARD_W, CARD_H, RADIUS } from '../objects/cardLayout'
import { ITEM_CARD_W, ITEM_IMAGE_H, ITEM_RADIUS } from '../objects/itemLayout'
import { ITEMS } from '../items'
const CARD_IMAGES = [
{ id: 'queen', file: 'reine.png' },
@@ -15,10 +18,6 @@ const CARD_IMAGES = [
{ id: 'bishop', file: 'eveque.png' }
]
const CARD_W = 130
const CARD_H = 190
const RADIUS = 10
export class BootScene extends Phaser.Scene {
constructor() {
super('BootScene')
@@ -44,10 +43,15 @@ export class BootScene extends Phaser.Scene {
barFill.destroy()
})
// Charger les images brutes
// Charger les images brutes des personnages
CARD_IMAGES.forEach(({ id, file }) => {
this.load.image(`raw-${id}`, `/cards/${file}`)
})
// Charger les images brutes des objets
ITEMS.forEach(({ id, image }) => {
this.load.image(`raw-item-${id}`, `/items/${image}`)
})
}
create() {
@@ -56,6 +60,12 @@ export class BootScene extends Phaser.Scene {
this._generateClippedTexture(id)
})
// Pour chaque objet, générer une texture portrait (zone haute uniquement,
// le bas de la carte objet est réservé au texte d'effet côté ItemObject)
ITEMS.forEach(({ id }) => {
this._generateItemTexture(id)
})
this.scene.start('GameScene')
}
@@ -121,4 +131,62 @@ export class BootScene extends Phaser.Scene {
// Enregistrer comme texture Phaser
this.textures.addCanvas(outKey, canvas)
}
/**
* Crée une texture `item-{id}` de dimensions ITEM_CARD_W × ITEM_IMAGE_H
* (uniquement la zone portrait en haut de la carte objet, coins arrondis
* en haut seulement — le bas de la carte est une zone de texte gérée par
* ItemObject, pas par cette texture).
*/
_generateItemTexture(id) {
const rawKey = `raw-item-${id}`
const outKey = `item-${id}`
if (!this.textures.exists(rawKey)) return
if (this.textures.exists(outKey)) return
const srcImage = this.textures.get(rawKey).getSourceImage()
const w = ITEM_CARD_W
const h = ITEM_IMAGE_H
const r = ITEM_RADIUS
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
// Chemin arrondi seulement en haut (bas droit, ce sera raccordé à la
// zone de texte qui suit immédiatement en dessous dans ItemObject)
ctx.beginPath()
ctx.moveTo(r, 0)
ctx.lineTo(w - r, 0)
ctx.quadraticCurveTo(w, 0, w, r)
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.lineTo(0, r)
ctx.quadraticCurveTo(0, 0, r, 0)
ctx.closePath()
ctx.clip()
// Dessiner l'image en mode "cover" (ratio conservé, centré)
const scaleX = w / srcImage.width
const scaleY = h / srcImage.height
const scale = Math.max(scaleX, scaleY)
const dw = srcImage.width * scale
const dh = srcImage.height * scale
const ox = (w - dw) / 2
const oy = (h - dh) / 2
ctx.drawImage(srcImage, ox, oy, dw, dh)
// Léger dégradé bas pour raccorder proprement à la zone de texte
const gBot = ctx.createLinearGradient(0, h - 24, 0, h)
gBot.addColorStop(0, 'rgba(0,0,0,0)')
gBot.addColorStop(1, 'rgba(0,0,0,0.55)')
ctx.fillStyle = gBot
ctx.fillRect(0, h - 24, w, 24)
this.textures.addCanvas(outKey, canvas)
}
}
+475 -86
View File
@@ -1,9 +1,18 @@
import Phaser from 'phaser'
import { CardObject } from '../objects/CardObject'
import { CARD_W, CARD_H } from '../objects/cardLayout'
import { ItemBoardToken, ICON_SIZE } from '../objects/ItemBoardToken'
import { ITEMS, getItemById } from '../items'
// Marge depuis le bas du canvas pour éviter la barre Windows
const BOTTOM_MARGIN = 60
// Espacement max entre cartes en main et hauteur de la zone de main,
// dérivés de la taille réelle des cartes (cardLayout) pour rester cohérents
// si la taille des cartes change.
const HAND_SPACING_MAX = CARD_W + 35
const HAND_Y_OFFSET = CARD_H / 2 + 15
// Couleurs équipes (synchronisé avec le serveur)
const TEAM_COLORS = {
0: 0xcc2200,
@@ -12,7 +21,7 @@ const TEAM_COLORS = {
3: 0xb8860b
}
// Couleurs attributs
// Couleurs attributs (hex, pour les Graphics)
const ATTR_COLORS = {
food: 0xf0c030,
war: 0xdd2222,
@@ -20,6 +29,14 @@ const ATTR_COLORS = {
diplo: 0x3399ee
}
// Couleurs attributs (CSS, pour les Text — cohérent avec CardObject)
const ATTR_COLORS_CSS = {
food: '#f0c030',
war: '#ff5555',
health: '#4ade80',
diplo: '#60a5fa'
}
export class GameScene extends Phaser.Scene {
constructor() {
@@ -28,6 +45,15 @@ export class GameScene extends Phaser.Scene {
this.selectedCard = null
this.resolveObjects = []
this._canPlay = false // true uniquement quand c'est la phase 'selecting' locale
this._lastHandTurn = null // dernier tour pour lequel la main a été (re)construite
this._lastRoundResult = null // { playedCards, gains } affichés actuellement (phase resolve)
this._resizeTimer = null // debounce du redimensionnement du canvas
// ── Objets sur le plateau (jetons permanents, pas de panneau modal) ────────
this.itemTokens = [] // ItemBoardToken (boutique) actuellement affichés
this._shopState = null // { myItems, pendingBuyItemId, availableItems, firstPlayerName }
this.myItemTokens = [] // ItemBoardToken des objets que JE possède, à côté de ma main
this.opponentItemTokens = [] // ItemBoardToken des objets possédés par les autres joueurs
}
create() {
@@ -37,21 +63,62 @@ export class GameScene extends Phaser.Scene {
this._buildTable()
this._buildPlayButton()
this._buildItemTokens()
this._setupSocketListeners()
// Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue
// grandit après réception des vraies données de partie (jauges des joueurs).
// Sans repositionnement, les éléments d'UI (dont le bouton de confirmation)
// restent calés sur l'ancienne hauteur et finissent hors de la zone cliquable.
this.scale.on('resize', this._onResizeDebounced, this)
// IMPORTANT : Phaser n'appelle jamais une méthode `shutdown()` définie sur la
// Scene elle-même — il faut s'abonner explicitement à l'événement du cycle de
// vie pour libérer les listeners, sinon ils s'accumulent à chaque (re)création
// de la scène (navigation vers une nouvelle partie, restart, etc.).
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this._onShutdown, this)
this.events.once(Phaser.Scenes.Events.DESTROY, this._onShutdown, this)
this.socket.emit('request-state', { roomId: this.roomId })
}
_onShutdown() {
this.scale.off('resize', this._onResizeDebounced, this)
if (this._resizeTimer) {
clearTimeout(this._resizeTimer)
this._resizeTimer = null
}
if (this._errorTimer) {
clearTimeout(this._errorTimer)
this._errorTimer = null
}
}
/**
* Regroupe (debounce) les événements resize rapprochés : Scale.RESIZE peut
* émettre plusieurs événements en rafale (redimensionnement de fenêtre en
* direct, réflows successifs du HUD) ; sans regroupement, chaque événement
* déclencherait une reconstruction destructive complète (slots, légende)
* et de nouveaux tweens sur la main, ce qui dégraderait les performances.
*/
_onResizeDebounced(gameSize) {
if (this._resizeTimer) clearTimeout(this._resizeTimer)
this._resizeTimer = setTimeout(() => {
this._resizeTimer = null
this._onResize(gameSize)
}, 120)
}
// ─── Construction du plateau ─────────────────────────────────────────────────
_buildTable() {
const { width, height } = this.scale
// Fond parchemin
this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500)
this.bgRect = this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500)
// Zone de jeu centrale
this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7)
this.zoneRect = this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7)
.setStrokeStyle(1, 0xd4a73230)
// Titre du tour
@@ -73,45 +140,121 @@ export class GameScene extends Phaser.Scene {
this.slotObjects = [] // pour pouvoir les détruire/recréer
// Légende attributs (coin en bas à droite, au-dessus de la marge Windows)
this.legendObjects = []
this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10)
}
/**
* Repositionne tous les éléments dépendants de la taille du canvas.
* Nécessaire car le canvas Phaser (mode Scale.RESIZE) se redimensionne
* une fois que le HUD Vue grandit (jauges des joueurs affichées), ce qui
* décale la zone visible sans que les éléments d'UI ne soient recalés —
* rendant notamment le bouton de confirmation hors-zone cliquable.
*/
_onResize(gameSize) {
const width = gameSize.width
const height = gameSize.height
// Fond + zone centrale
if (this.bgRect) {
this.bgRect.setPosition(width / 2, height / 2)
this.bgRect.setSize(width, height)
}
if (this.zoneRect) {
this.zoneRect.setPosition(width / 2, height / 2 - 60)
this.zoneRect.setSize(width * 0.7, height * 0.38)
}
// Textes du haut
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)
// 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
// 'resolve'/'end') si on ne les redessine pas aussi — donc on les
// reconstruit ensemble pour rester synchronisés.
if (this.playSlots.length) {
this._buildSlots(this.playSlots.length)
if (this._lastRoundResult) {
this._showPlayedCards(this._lastRoundResult.playedCards, this._lastRoundResult.gains)
}
}
// Bouton de confirmation + compteur
this._layoutPlayButton()
// Bande de jetons d'objet en haut du plateau
this._layoutItemTokens()
// Main du joueur (repositionnement simple, sans reconstruction)
this._repositionHand()
}
_buildSlots(playerCount) {
const { width, height } = this.scale
// Détruire les anciens slots
// Détruire les anciens slots (et les mini-cartes objets des adversaires)
this.slotObjects.forEach(o => o.destroy())
this.slotObjects = []
this.playSlots = []
this.opponentItemTokens.forEach(t => t.destroy())
this.opponentItemTokens = []
const slotY = height / 2 - 60
const spacing = 140
const spacing = CARD_W + 30
const totalW = playerCount * spacing
const startX = width / 2 - totalW / 2 + spacing / 2
for (let i = 0; i < playerCount; i++) {
const x = startX + i * spacing
const player = this.store.players[i]
const g = this.add.graphics()
g.lineStyle(1, 0xd4a73240)
g.strokeRoundedRect(-65, -95, 130, 190, 8)
g.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 8)
g.x = x
g.y = slotY
this.slotObjects.push(g)
const label = this.add.text(x, slotY + 108,
this.store.players[i]?.name ?? `J${i + 1}`, {
player?.name ?? `J${i + 1}`, {
fontSize: '11px',
fontFamily: 'Georgia',
color: '#4a3020'
}).setOrigin(0.5)
this.slotObjects.push(label)
// Mini-cartes objets "posées devant" cet adversaire, juste sous son
// slot de jeu — comme sur ma propre table à côté de ma main. On ne le
// fait pas pour MON propre slot : mes objets sont déjà affichés à côté
// de ma main (plus pertinent puisque je vois ma main, pas mon slot).
const isMe = player?.teamIndex === this.store.myTeamIndex
if (!isMe) {
const ownedItems = player?.items ?? []
const tokenGap = ICON_SIZE * 0.55 + 6
const startTokenX = x - (ownedItems.length - 1) * tokenGap / 2
ownedItems.forEach((itemId, k) => {
const item = getItemById(itemId)
if (!item) return
// displayScale=0.55 réduit uniquement le jeton compact ; la carte
// détaillée au survol reste à sa taille normale (voir ItemBoardToken).
const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY + 128, item, 'owned', false, 0.55)
this.opponentItemTokens.push(token)
})
}
this.playSlots.push({ x, y: slotY })
}
}
_buildAttrLegend(x, y) {
// Détruire l'ancienne légende avant reconstruction (appelé aussi au resize)
if (this.legendObjects) this.legendObjects.forEach(o => o.destroy())
this.legendObjects = []
const entries = [
{ attr: 'food', label: '🟡 Nourriture — haut gauche' },
{ attr: 'war', label: '🔴 Guerre — haut droite' },
@@ -120,17 +263,24 @@ export class GameScene extends Phaser.Scene {
]
entries.forEach((e, i) => {
this.add.text(x, y - i * 16, e.label, {
fontSize: '10px',
fontFamily: 'Georgia',
color: '#6a5040'
}).setOrigin(1, 1)
this.legendObjects.push(
this.add.text(x, y - i * 16, e.label, {
fontSize: '10px',
fontFamily: 'Georgia',
color: '#6a5040'
}).setOrigin(1, 1)
)
})
}
/** Position Y (unique source de vérité) du bouton de confirmation et de son fond. */
_playButtonY() {
return this.scale.height - BOTTOM_MARGIN - 20
}
_buildPlayButton() {
const { width, height } = this.scale
const btnY = height - BOTTOM_MARGIN - 20
const { width } = this.scale
const btnY = this._playButtonY()
// Fond du bouton (rectangle arrondi via Graphics)
this.playBtnBg = this.add.graphics()
@@ -160,9 +310,21 @@ export class GameScene extends Phaser.Scene {
}).setOrigin(0.5).setDepth(100)
}
/** Recale le bouton de confirmation et son compteur sur la taille actuelle du canvas. */
_layoutPlayButton() {
const { width } = this.scale
const btnY = this._playButtonY()
if (this.playBtn) this.playBtn.setPosition(width / 2, btnY)
if (this.readyText) this.readyText.setPosition(width / 2, btnY + 28)
// Redessiner le fond du bouton (dépend de width/height) en conservant son état actif/inactif
this._drawBtnBg(!!this._pulseTween)
}
_drawBtnBg(active) {
const { width, height } = this.scale
const btnY = height - BOTTOM_MARGIN - 20
const { width } = this.scale
const btnY = this._playButtonY()
const btnW = 280
const btnH = 42
this.playBtnBg.clear()
@@ -201,27 +363,174 @@ export class GameScene extends Phaser.Scene {
if (this._pulseTween) { this._pulseTween.stop(); this._pulseTween = null }
}
// ─── Objets sur le plateau (jetons permanents, agrandissement au survol) ────
/**
* Colonne verticale sur le bord GAUCHE du plateau (au lieu d'une bande
* horizontale en haut). La bande horizontale précédente plaçait les jetons
* trop près du bord supérieur du canvas : la carte agrandie au survol
* (hauteur ~172px, centrée sur le jeton) débordait au-dessus de y=0 et se
* retrouvait coupée/invisible sous la barre HUD, rendant l'achat impossible
* en pratique. La colonne à gauche laisse assez de marge verticale ET
* horizontale (largeur de carte ~132px) pour que la carte agrandie
* s'affiche toujours entièrement à l'intérieur du canvas.
*/
_itemTokensX() {
return 70
}
_itemTokensStartY() {
return 130
}
_itemTokensSpacing() {
return ICON_SIZE + 60 // marge verticale généreuse pour la carte agrandie (H ~172px)
}
/**
* (Re)construit la colonne à gauche avec UNIQUEMENT les objets encore
* disponibles à l'achat. Une fois un objet acheté (par n'importe quel
* joueur), il disparaît complètement de cette colonne — inutile de
* l'afficher grisé/"INDISPONIBLE" puisqu'il ne peut plus jamais être
* acheté par personne (stock unique pour toute la partie). Les objets
* restants remontent pour combler l'espace laissé vide.
*/
_buildItemTokens() {
this.itemTokens.forEach(t => t.destroy())
this.itemTokens = []
const x = this._itemTokensX()
const startY = this._itemTokensStartY()
const spacing = this._itemTokensSpacing()
const state = this._shopState ?? { myItems: [], availableItems: ITEMS.map(i => i.id) }
// Ne garder que les objets réellement encore en stock. "owned" (déjà
// possédé par MOI) est logiquement un sous-cas exclu de availableItems
// côté serveur, mais on l'exclut aussi explicitement par sécurité.
const stillAvailable = ITEMS.filter(itemData =>
state.availableItems.includes(itemData.id) && !state.myItems.includes(itemData.id)
)
// Libellé au-dessus de la colonne : sans lui, rien n'indique que ces
// petites icônes sont cliquables pour acheter (elles n'ont ni bouton ni
// panneau visible en permanence, seul un survol révèle la carte détaillée).
// Masqué si plus aucun objet n'est disponible (colonne vide).
if (stillAvailable.length > 0) {
if (!this.itemTokensLabel) {
this.itemTokensLabel = this.add.text(x, startY - ICON_SIZE / 2 - 16, 'OBJETS\n(survoler)', {
fontSize: '9px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#6a5040', align: 'center'
}).setOrigin(0.5, 1)
} else {
this.itemTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
this.itemTokensLabel.setVisible(true)
}
} else if (this.itemTokensLabel) {
this.itemTokensLabel.setVisible(false)
}
// Jauges du joueur local, pour déterminer si le bouton ACHETER de chaque
// carte doit être cliquable (jauge suffisante) ou grisé.
const me = this.store.players.find(p => p.teamIndex === this.store.myTeamIndex)
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 token = new ItemBoardToken(this, x, startY + i * spacing, itemData, 'available', affordable)
token.on('buy', (data) => this._onBuyItem(data))
this.itemTokens.push(token)
})
}
/** Recale la colonne de jetons verticalement sur le bord gauche du canvas. */
_layoutItemTokens() {
const x = this._itemTokensX()
const startY = this._itemTokensStartY()
const spacing = this._itemTokensSpacing()
if (this.itemTokensLabel) this.itemTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
this.itemTokens.forEach((token, i) => token.setPosition(x, startY + i * spacing))
}
_onBuyItem(itemData) {
this.socket.emit('buy-item', { roomId: this.roomId, itemId: itemData.id })
}
/**
* (Re)construit les mini-cartes des objets que JE possède, affichées juste
* à droite de ma main — comme si je les avais posées devant moi sur la
* table, à portée de main avec mes cartes personnage.
*/
_buildMyItemTokens() {
this.myItemTokens.forEach(t => t.destroy())
this.myItemTokens = []
const myItemIds = this._shopState?.myItems ?? []
if (myItemIds.length === 0) return
myItemIds.forEach((itemId, i) => {
const itemData = ITEMS.find(it => it.id === itemId)
if (!itemData) return
// Toujours 'owned' ici : ce sont mes objets déjà achetés, non cliquables.
// displayScale=0.7 réduit uniquement le jeton compact ; la carte
// détaillée au survol reste à sa taille normale (voir ItemBoardToken).
const token = new ItemBoardToken(this, 0, 0, itemData, 'owned', false, 0.7)
this.myItemTokens.push(token)
})
this._layoutMyItemTokens()
}
/** Positionne mes mini-cartes objets juste à droite de ma main de cartes. */
_layoutMyItemTokens() {
if (this.myItemTokens.length === 0) return
const { handY, spacing, startX } = this._handGeometry(this.handCards.length)
const handRightEdge = startX + Math.max(this.handCards.length - 1, 0) * spacing + CARD_W / 2
const gap = 18
const tokenGap = ICON_SIZE * 0.7 + 10
this.myItemTokens.forEach((token, i) => {
token.setPosition(handRightEdge + gap + ICON_SIZE * 0.7 / 2 + i * tokenGap, handY)
})
}
// ─── Main du joueur ──────────────────────────────────────────────────────────
_buildHand(cards) {
/**
* Calcule la géométrie de la main (position Y, espacement, X de départ)
* pour un nombre de cartes donné. Centralisé ici pour éviter la triplication
* de cette formule (_buildHand / _animateDraw / _repositionHand la
* calculaient chacune séparément), et réutilisé pour positionner mes
* objets achetés juste à côté de la main.
*/
_handGeometry(cardCount) {
const { width, height } = this.scale
const n = Math.max(cardCount, 1)
const handY = height - BOTTOM_MARGIN - HAND_Y_OFFSET
const spacing = Math.min(HAND_SPACING_MAX, (width * 0.85) / n)
const totalW = spacing * (n - 1)
const startX = width / 2 - totalW / 2
return { handY, spacing, startX }
}
_buildHand(cards) {
// Vider l'ancienne main
this.handCards.forEach(c => c.destroy())
this.handCards = []
if (!cards || cards.length === 0) return
const handY = height - BOTTOM_MARGIN - 110
const spacing = Math.min(150, (width * 0.85) / cards.length)
const totalW = spacing * (cards.length - 1)
const startX = width / 2 - totalW / 2
const { handY, spacing, startX } = this._handGeometry(cards.length)
cards.forEach((cardData, i) => {
const card = new CardObject(this, startX + i * spacing, handY, cardData)
card.on('select', this._onCardSelected, this)
this.handCards.push(card)
})
this._layoutMyItemTokens()
}
_onCardSelected(card) {
@@ -261,14 +570,23 @@ export class GameScene extends Phaser.Scene {
* @param {Array} gains - gains[i] = { food, war, health, diplo } pour le joueur i
*/
_showPlayedCards(playedCards, gains = []) {
// Mémoriser le dernier résultat affiché pour pouvoir le redessiner si les
// slots sont reconstruits pendant la phase 'resolve' (ex: resize du canvas).
this._lastRoundResult = { playedCards, gains }
this.resolveObjects.forEach(o => o.destroy())
this.resolveObjects = []
// Style épuré, cohérent avec CardObject : portrait plein cadre, fin
// liseré couleur équipe, nom simple en bas, valeurs discrètes aux coins.
const rCARD_W = CARD_W
const rCARD_H = CARD_H
const ATTRS_POS = [
{ key: 'food', label: 'NOU', dx: -28, dy: -20 },
{ key: 'war', label: 'GUE', dx: 28, dy: -20 },
{ key: 'health', label: 'SAN', dx: -28, dy: 30 },
{ key: 'diplo', label: 'DIP', dx: 28, dy: 30 }
{ key: 'food', dx: -rCARD_W / 2 + 12, dy: -rCARD_H / 2 + 12 },
{ key: 'war', dx: rCARD_W / 2 - 12, dy: -rCARD_H / 2 + 12 },
{ key: 'health', dx: -rCARD_W / 2 + 12, dy: rCARD_H / 2 - 34 },
{ key: 'diplo', dx: rCARD_W / 2 - 12, dy: rCARD_H / 2 - 34 }
]
playedCards.forEach((entry, i) => {
@@ -277,67 +595,64 @@ export class GameScene extends Phaser.Scene {
const teamColor = TEAM_COLORS[entry.teamIndex] ?? 0x888888
const gain = gains[i] ?? {}
const textureKey = `card-${entry.cardId}`
const hasTexture = entry.cardId && this.textures.exists(textureKey)
// ── Fond carte ──────────────────────────────────────────
const g = this.add.graphics()
g.fillStyle(this._darken(teamColor, 0.25))
g.fillRoundedRect(-55, -90, 110, 180, 6)
g.lineStyle(2, teamColor)
g.strokeRoundedRect(-55, -90, 110, 180, 6)
g.x = slot.x
g.y = slot.y
this.resolveObjects.push(g)
// ── Portrait ou fond de secours ────────────────────────────
if (hasTexture) {
const img = this.add.image(slot.x, slot.y, textureKey)
this.resolveObjects.push(img)
} else {
const bg = this.add.graphics()
bg.fillStyle(this._darken(teamColor, 0.3))
bg.fillRoundedRect(-rCARD_W / 2, -rCARD_H / 2, rCARD_W, rCARD_H, 10)
bg.x = slot.x
bg.y = slot.y
this.resolveObjects.push(bg)
}
// ── Nom du joueur ────────────────────────────────────────
// ── Fin liseré couleur équipe ───────────────────────────────
const border = this.add.graphics()
border.lineStyle(1.5, teamColor, 0.9)
border.strokeRoundedRect(-rCARD_W / 2, -rCARD_H / 2, rCARD_W, rCARD_H, 10)
border.x = slot.x
border.y = slot.y
this.resolveObjects.push(border)
// ── Nom du joueur (au-dessus de la carte) ──────────────────
this.resolveObjects.push(
this.add.text(slot.x, slot.y - 78, entry.playerName, {
fontSize: '10px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
this.add.text(slot.x, slot.y - rCARD_H / 2 - 14, entry.playerName, {
fontSize: '11px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
}).setOrigin(0.5)
)
// ── Nom de la carte ──────────────────────────────────────
// ── Nom de la carte, simple texte en bas ────────────────────
this.resolveObjects.push(
this.add.text(slot.x, slot.y - 62, entry.cardName, {
fontSize: '12px', fontFamily: 'Georgia', color: '#d4a732', fontStyle: 'bold'
this.add.text(slot.x, slot.y + rCARD_H / 2 - 14, entry.cardName, {
fontSize: '13px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#f5e6c8', stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5)
)
// ── 4 badges attribut + gain ─────────────────────────────
ATTRS_POS.forEach(({ key, label, dx, dy }) => {
// ── Valeurs discrètes aux 4 coins + gain au-dessus si positif ────────
ATTRS_POS.forEach(({ key, dx, dy }) => {
const cx = slot.x + dx
const cy = slot.y + dy
const cardVal = entry[key] ?? 0
const gainVal = gain[key] ?? 0
// Badge fond coloré
const badge = this.add.graphics()
badge.fillStyle(ATTR_COLORS[key], gainVal > 0 ? 1.0 : 0.45)
badge.fillRoundedRect(-22, -20, 44, 40, 4)
badge.x = cx
badge.y = cy
this.resolveObjects.push(badge)
// Valeur de la carte
this.resolveObjects.push(
this.add.text(cx, cy - 7, String(cardVal), {
fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#ffffff', stroke: '#000000', strokeThickness: 2
this.add.text(cx, cy, String(cardVal), {
fontSize: '15px', fontFamily: 'Georgia', fontStyle: 'bold',
color: ATTR_COLORS_CSS[key], stroke: '#000000', strokeThickness: 3.5
}).setOrigin(0.5)
)
// Label abrégé
this.resolveObjects.push(
this.add.text(cx, cy + 5, label, {
fontSize: '7px', fontFamily: 'Arial', color: '#ffffffcc'
}).setOrigin(0.5)
)
// Gain (affiché en vert si > 0)
if (gainVal > 0) {
this.resolveObjects.push(
this.add.text(cx, cy + 16, `+${gainVal}`, {
fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#ffffff', stroke: '#000000', strokeThickness: 2
this.add.text(cx, cy - 16, `+${gainVal}`, {
fontSize: '11px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#7CFC9A', stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5)
)
}
@@ -352,22 +667,69 @@ export class GameScene extends Phaser.Scene {
this.store.updateState(state)
this.turnText.setText(`Tour ${state.currentTurn} / ${state.maxTurns}`)
// Mémoriser l'état des objets (possédés/disponibles, priorité du tour)
// et ne rafraîchir les jetons du plateau que si leur statut, OU les
// jauges du joueur local (qui déterminent si le bouton ACHETER est
// cliquable), ont réellement changé. Sans cette garde, la reconstruction
// se déclencherait à chaque 'game-state' reçu (rediffusé à tous à chaque
// carte jouée par n'importe qui), ce qui interromprait l'agrandissement
// au survol en plein milieu.
const newShopState = {
myItems: state.myItems ?? [],
pendingBuyItemId: state.pendingBuyItemId ?? null,
availableItems: state.availableItems ?? [],
firstPlayerName: state.firstPlayerName ?? null
}
const me = (state.players ?? []).find(p => p.teamIndex === state.myTeamIndex)
const myGaugesKey = JSON.stringify(me?.gauges ?? {})
const shopChanged = !this._shopState ||
JSON.stringify(this._shopState.myItems) !== JSON.stringify(newShopState.myItems) ||
JSON.stringify(this._shopState.availableItems) !== JSON.stringify(newShopState.availableItems) ||
this._lastMyGaugesKey !== myGaugesKey
this._shopState = newShopState
this._lastMyGaugesKey = myGaugesKey
if (shopChanged) this._buildItemTokens()
// Reconstruire mes mini-cartes objets uniquement si MA liste d'objets
// possédés a changé (indépendant de shopChanged qui réagit aussi aux
// objets encore disponibles / jauges, non pertinents ici).
const myItemsKey = JSON.stringify(newShopState.myItems)
if (this._lastMyItemsKey !== myItemsKey) {
this._lastMyItemsKey = myItemsKey
this._buildMyItemTokens()
}
if (state.phase === 'selecting') {
this._canPlay = true
this.resolveObjects.forEach(o => o.destroy())
this.resolveObjects = []
this._lastRoundResult = null
this.phaseText.setText('Choisissez une carte puis confirmez')
this.readyText.setText('')
this._deactivateBtn()
// (Re)construire les slots si le nombre de joueurs a changé ou premier tour
if (this.playSlots.length !== state.players.length) {
// (Re)construire les slots si le nombre de joueurs a changé (premier
// tour), OU si la liste d'objets possédés par un adversaire a changé
// (leurs mini-cartes objets, affichées sous leur slot, doivent alors
// être mises à jour).
const opponentItemsKey = JSON.stringify(
state.players.map(p => p.items ?? [])
)
if (this.playSlots.length !== state.players.length || this._lastOpponentItemsKey !== opponentItemsKey) {
this._lastOpponentItemsKey = opponentItemsKey
this._buildSlots(state.players.length)
}
// Si ce n'est pas le premier tour, animer la pioche avant d'afficher la main
if (state.currentTurn > 1) {
this._animateDraw(state.hand)
} else {
this._buildHand(state.hand)
// Ne (re)construire/animer la main que lorsque le tour a réellement changé.
// Le serveur rediffuse 'game-state' à tous les joueurs à chaque carte jouée
// par n'importe qui ; sans cette garde, la main serait reconstruite/animée
// plusieurs fois pour le même tour, provoquant un doublon visuel de carte.
if (this._lastHandTurn !== state.currentTurn) {
this._lastHandTurn = state.currentTurn
// Si ce n'est pas le premier tour, animer la pioche avant d'afficher la main
if (state.currentTurn > 1) {
this._animateDraw(state.hand)
} else {
this._buildHand(state.hand)
}
}
}
@@ -389,11 +751,43 @@ export class GameScene extends Phaser.Scene {
}
})
this.socket.on('round-result', ({ playedCards, gains }) => {
this.socket.on('round-result', ({ playedCards, gains, boughtItems }) => {
this.phaseText.setText('Résolution du tour...')
this.readyText.setText('')
this._showPlayedCards(playedCards, gains)
if (boughtItems && boughtItems.length > 0) {
const names = boughtItems.map(b => `${b.playerName} → ${b.itemName}`).join(' | ')
this.readyText.setText(`🛒 ${names}`)
}
})
// Retour visuel des refus d'achat (ex: jauge insuffisante, objet déjà
// pris) : sans cet écouteur, le serveur répond bien via socket.emit('error',
// ...) mais rien côté client n'affichait jamais ce message — un clic sur
// une carte objet semblait alors ne rien faire, laissant croire qu'il
// n'y avait aucun moyen d'acheter.
this.socket.on('error', ({ message }) => {
if (!message) return
this._showErrorToast(message)
})
}
/** Affiche un message d'erreur temporaire (ex: achat refusé) près du bouton de confirmation. */
_showErrorToast(message) {
if (this._errorText) { this._errorText.destroy(); this._errorText = null }
if (this._errorTimer) { clearTimeout(this._errorTimer); this._errorTimer = null }
const { width } = this.scale
this._errorText = this.add.text(width / 2, this._playButtonY() - 46, `⚠ ${message}`, {
fontSize: '13px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#ff6b6b', stroke: '#000000', strokeThickness: 3
}).setOrigin(0.5).setDepth(150)
this._errorTimer = setTimeout(() => {
if (this._errorText) { this._errorText.destroy(); this._errorText = null }
this._errorTimer = null
}, 3000)
}
// ─── Animation pioche ────────────────────────────────────────────────────────
@@ -403,8 +797,6 @@ export class GameScene extends Phaser.Scene {
* (toujours la dernière du tableau, ajoutée par nextTurn côté serveur)
*/
_animateDraw(cards) {
const { width, height } = this.scale
// Afficher immédiatement les cartes déjà en main (toutes sauf la dernière)
const existing = cards.slice(0, -1)
const newCard = cards[cards.length - 1]
@@ -414,11 +806,8 @@ export class GameScene extends Phaser.Scene {
if (!newCard) return
// Calculer la position finale de la nouvelle carte dans la main complète
const handY = height - BOTTOM_MARGIN - 110
const spacing = Math.min(150, (width * 0.85) / cards.length)
const totalW = spacing * (cards.length - 1)
const startX = width / 2 - totalW / 2
const finalX = startX + (cards.length - 1) * spacing
const { handY, spacing, startX } = this._handGeometry(cards.length)
const finalX = startX + (cards.length - 1) * spacing
// Créer la carte hors écran (au-dessus)
const card = new CardObject(this, finalX, -200, newCard)
@@ -446,12 +835,10 @@ export class GameScene extends Phaser.Scene {
/** Repositionne toutes les cartes en main avec le bon espacement */
_repositionHand() {
const { width, height } = this.scale
const handY = height - BOTTOM_MARGIN - 110
const n = this.handCards.length
const spacing = Math.min(150, (width * 0.85) / n)
const totalW = spacing * (n - 1)
const startX = width / 2 - totalW / 2
const n = this.handCards.length
if (n === 0) return
const { handY, spacing, startX } = this._handGeometry(n)
this.handCards.forEach((card, i) => {
const targetX = startX + i * spacing
@@ -464,6 +851,8 @@ export class GameScene extends Phaser.Scene {
ease: 'Power2'
})
})
this._layoutMyItemTokens()
}
// ─── Utilitaires ─────────────────────────────────────────────────────────────