feat: cartes Événement (pioche partagée de 18 cartes, 6 effets, synergies, ciblage)

- Nouveau server/src/game/events.js : 6 événements (Récolte exceptionnelle,
  Épidémie, Levée des troupes, Accord royal, Complot découvert, Grande fête
  au château), deck PARTAGÉ de 18 cartes (3 exemplaires de chacun) mélangé
  au démarrage de la partie.
- Room.js : buyEventDraw() (coût fixe 1 point dans chacune des 4 jauges,
  effet immédiat contrairement aux objets/bâtiments — pas de conflit de
  stock possible), playEventCard() (choix de jouer une carte de sa main,
  max 1 par tour, avec ciblage optionnel), _resolvePendingEventPlays()
  (résolution de TOUS les événements du tour AVANT le calcul des gains de
  cartes personnage, défausse systématique, protection "Complot découvert"
  pour soi-même uniquement). Les 6 effets incluent leurs synergies avec les
  objets/bâtiments existants (Charrue, Trousse+Église pour Épidémie,
  Trébuchet, Sceau Royal, Château Royal → pioche bonus).
- IMPORTANT : le ciblage de joueur (Accord royal, variante Trousse
  d'Épidémie) transite par teamIndex (identifiant public) et non socketId
  (jamais exposé au client via getPlayersInfo/getStateFor) — le serveur
  résout teamIndex -> joueur réel au moment de la résolution.
- Nouvel event socket 'buy-event-draw' (effet immédiat) et 'play-event'
  (résolution différée), eventResults inclus dans round-result.
- Client : events.js (miroir), EventCardObject.js (carte épurée façon
  CardObject, badge rouge "!" pour les événements à effet négatif).
  BootScene génère les textures plein cadre (méthode _generateClippedTexture
  généralisée, réutilisée avec les personnages).
- GameScene : bouton de pioche à droite du bouton de confirmation (état
  grisé selon jauges/main pleine/pioche déjà tentée), seconde main de cartes
  événement (max 2) au-dessus de ce bouton, sélection d'une carte pour la
  jouer avec séquence de ciblage interactive (surbrillance des slots
  adversaires cliquables) pour Accord royal et la variante Trousse
  d'Épidémie.
- 61 nouveaux tests unitaires serveur (events.js + Room.js : pioche, choix
  de jouer, résolution des 6 effets avec/sans synergie, Complot découvert,
  ordre événements-avant-personnages, deck partagé).
- Nouveaux helpers et tests d'intégration E2E Playwright (e2e/tests/events.spec.js)
  pour la pioche, la limite de main à 2, et les 6 effets — certains
  scénarios restent probabilistes (pioche aléatoire dans le deck partagé) et
  peuvent nécessiter une stabilisation supplémentaire du timing.
This commit is contained in:
Kevin L'Hereec committed 2026-08-21 17:05:50 +02:00
1 parent 9139989fa7
commit 1726f8f96b
17 files changed
+1952 -16

No files matched your search

+278 -3
View File
@@ -2,8 +2,10 @@ 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 { EventCardObject } from '../objects/EventCardObject'
import { ITEMS, getItemById } from '../items'
import { BUILDINGS, getBuildingById } from '../buildings'
import { EVENTS, getEventById, DRAW_COST } from '../events'
// Marge depuis le bas du canvas pour éviter la barre Windows
const BOTTOM_MARGIN = 60
@@ -60,6 +62,12 @@ export class GameScene extends Phaser.Scene {
this.buildingTokens = [] // ItemBoardToken (boutique bâtiments, à droite)
this.myBuildingTokens = [] // ItemBoardToken des bâtiments que JE possède, à côté de ma main
this.opponentBuildingTokens = [] // ItemBoardToken des bâtiments possédés par les autres joueurs
// ── Cartes Événement (seconde main, max 2, sous ma main de personnages) ────
this.eventCards = [] // EventCardObject actuellement affichées (ma main d'événements)
this.selectedEventCard = null // EventCardObject choisie pour être jouée ce tour (avant confirmation)
this._lastEventHandKey = null // pour ne reconstruire qu'au vrai changement de main d'événements
this._pendingEventTargets = {} // cibles choisies pour l'événement en cours de sélection/ciblage
}
create() {
@@ -71,6 +79,7 @@ export class GameScene extends Phaser.Scene {
this._buildPlayButton()
this._buildItemTokens()
this._buildBuildingTokens()
this._buildEventDrawButton()
this._setupSocketListeners()
// Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue
@@ -99,6 +108,7 @@ export class GameScene extends Phaser.Scene {
clearTimeout(this._errorTimer)
this._errorTimer = null
}
this._cancelTargeting()
}
/**
@@ -197,6 +207,10 @@ export class GameScene extends Phaser.Scene {
this._layoutItemTokens()
this._layoutBuildingTokens()
// Bouton de pioche d'événement + ma main d'événements
this._layoutEventDrawButton()
this._layoutEventHand()
// Main du joueur (repositionnement simple, sans reconstruction)
this._repositionHand()
}
@@ -633,6 +647,252 @@ export class GameScene extends Phaser.Scene {
})
}
// ─── Cartes Événement (pioche + seconde main + ciblage) ────────────────────
/** Position du bouton de pioche d'événement : à droite du bouton de confirmation, même Y. */
_eventDrawButtonPos() {
const { width } = this.scale
return { x: width / 2 + 210, y: this._playButtonY() }
}
_buildEventDrawButton() {
const { x, y } = this._eventDrawButtonPos()
this.eventDrawBtnBg = this.add.graphics().setDepth(99)
this.eventDrawBtn = this.add.text(x, y, '🎴 Piocher un Événement', {
fontSize: '12px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#d4a732', padding: { x: 10, y: 6 }
})
.setOrigin(0.5)
.setDepth(100)
.setInteractive({ cursor: 'pointer' })
.on('pointerdown', () => this._onBuyEventDraw())
.on('pointerover', () => this.eventDrawBtn.setScale(1.05))
.on('pointerout', () => this.eventDrawBtn.setScale(1))
this._drawEventDrawBtnBg()
}
_drawEventDrawBtnBg() {
const { x, y } = this._eventDrawButtonPos()
const w = 190, h = 30
this.eventDrawBtnBg.clear()
this.eventDrawBtnBg.fillStyle(0x2a1a0a, 0.9)
this.eventDrawBtnBg.fillRoundedRect(x - w / 2, y - h / 2, w, h, 6)
this.eventDrawBtnBg.lineStyle(1.5, 0xd4a73280)
this.eventDrawBtnBg.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 6)
}
_layoutEventDrawButton() {
const { x, y } = this._eventDrawButtonPos()
if (this.eventDrawBtn) this.eventDrawBtn.setPosition(x, y)
this._drawEventDrawBtnBg()
}
/**
* Met à jour l'apparence du bouton de pioche selon que le tirage est
* réellement possible : jauges suffisantes (DRAW_COST = 1 point dans
* chacune des 4), main d'événements pas déjà pleine, et pas de pioche déjà
* tentée ce tour. Grisé sinon, cohérent avec le pattern déjà utilisé pour
* les boutons ACHETER des objets/bâtiments.
*/
_refreshEventDrawButtonState(myGauges, myEventHandSize, hasPendingDraw) {
if (!this.eventDrawBtn) return
const affordable = Object.entries(DRAW_COST).every(([attr, cost]) => (myGauges?.[attr] ?? 0) >= cost)
const handFull = myEventHandSize >= 2
const canDraw = affordable && !handFull && !hasPendingDraw
this.eventDrawBtn.setColor(canDraw ? '#d4a732' : '#6a5040')
this.eventDrawBtn.disableInteractive()
if (canDraw) this.eventDrawBtn.setInteractive({ cursor: 'pointer' })
let label = '🎴 Piocher un Événement'
if (handFull) label = '🎴 Main pleine (2/2)'
else if (hasPendingDraw) label = '🎴 Pioche faite ce tour'
else if (!affordable) label = '🎴 Piocher (1 pt/jauge)'
this.eventDrawBtn.setText(label)
}
_onBuyEventDraw() {
this.socket.emit('buy-event-draw', { roomId: this.roomId })
}
/** Position/espacement de ma main de cartes événement (max 2), au-dessus de mon bouton de pioche. */
_eventHandGeometry() {
const { x } = this._eventDrawButtonPos()
const y = this._playButtonY() - 100
return { x, y, spacing: 90 }
}
/**
* (Re)construit ma main de cartes événement (max EVENT_HAND_MAX=2), à
* l'échelle réduite pour rester compacte au-dessus du bouton de pioche.
*/
_buildEventHand(eventIds) {
this.eventCards.forEach(c => c.destroy())
this.eventCards = []
this.selectedEventCard = null
if (!eventIds || eventIds.length === 0) return
const { x, y, spacing } = this._eventHandGeometry()
const startX = x - (eventIds.length - 1) * spacing / 2
eventIds.forEach((eventId, i) => {
const eventData = getEventById(eventId)
if (!eventData) return
const card = new EventCardObject(this, startX + i * spacing, y, eventData)
card.setScale(0.55)
card.on('select', this._onEventCardSelected, this)
this.eventCards.push(card)
})
}
_layoutEventHand() {
if (this.eventCards.length === 0) return
const { x, y, spacing } = this._eventHandGeometry()
const startX = x - (this.eventCards.length - 1) * spacing / 2
this.eventCards.forEach((card, i) => {
card.setPosition(startX + i * spacing, y)
card._baseY = y
})
}
/**
* Sélection d'une carte événement pour la jouer ce tour (une seule
* autorisée). Si l'événement nécessite un ciblage, ouvre la séquence de
* ciblage (clic sur les slots adversaires) avant d'émettre 'play-event' —
* sinon l'émission est immédiate, sans bouton de confirmation séparé
* (même pattern que l'achat d'objet/bâtiment : action -> résolution différée
* côté serveur).
*
* Choix pragmatique : le ciblage d'Accord royal (spécifié "au moment de
* piocher") est en réalité demandé ici, au moment de la SÉLECTION pour
* jouer, plutôt qu'immédiatement après le tirage. Cela évite un bug réel
* de correspondance d'index côté client si le joueur possède 2 exemplaires
* du même événement en main (le tableau `myEventHand` ne porte aucun
* identifiant d'instance, seulement des ids répétables) tout en envoyant
* les cibles au serveur exactement au même endroit du flux qu'attendu.
*/
_onEventCardSelected(card) {
if (this.selectedEventCard) return // déjà une carte sélectionnée ce tour
if (this._targetingState) return // ciblage déjà en cours
const event = card.eventData
const needsTarget = event.needsTargetOnDraw ||
(event.needsTargetOnPlay && this._epidemicNeedsTarget())
if (!needsTarget) {
this._confirmPlayEvent(card, {})
return
}
if (event.id === 'royal_accord') {
this._startTargeting(card, ['gainTargetSocketId', 'loseTargetSocketId'], {
gainTargetSocketId: 'Choisissez un joueur qui gagnera 1 Diplomatie',
loseTargetSocketId: 'Choisissez un joueur qui perdra 1 Diplomatie'
})
} else if (event.id === 'epidemic') {
this._startTargeting(card, ['targetSocketId'], {
targetSocketId: 'Choisissez l\'adversaire qui perdra 2 Santé (Trousse du Médecin)'
})
}
}
/** Vrai si jouer Épidémie maintenant déclencherait la variante Trousse (nécessite un ciblage). */
_epidemicNeedsTarget() {
const myItems = this.store.myItems ?? []
const myBuildings = this.store.myBuildings ?? []
// La variante Église (si possédée) prend le pas et ne nécessite aucun
// ciblage (tous les adversaires affectés automatiquement) — cohérent
// avec la priorité Église > Trousse déjà appliquée côté serveur.
return myItems.includes('medkit') && !myBuildings.includes('church')
}
/**
* Démarre une séquence de ciblage : met en évidence les slots adversaires
* cliquables, affiche l'instruction correspondant à l'étape courante, et
* avance jusqu'à ce que toutes les clés de `keys` soient renseignées, puis
* émet 'play-event' avec les cibles collectées.
*/
_startTargeting(card, keys, instructions) {
this._targetingState = { card, keys: [...keys], instructions, collected: {} }
this._highlightOpponentSlots(true)
this.phaseText.setText(instructions[keys[0]])
}
_highlightOpponentSlots(enable) {
this.playSlots.forEach((slot, i) => {
const player = this.store.players[i]
const isMe = player?.teamIndex === this.store.myTeamIndex
if (isMe || !player) return
if (enable) {
const hl = this.add.graphics().setDepth(60)
hl.lineStyle(3, 0xffd700, 1)
hl.strokeRoundedRect(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8, 10)
hl.x = slot.x
hl.y = slot.y
hl.setInteractive(
new Phaser.Geom.Rectangle(-CARD_W / 2 - 4, -CARD_H / 2 - 4, CARD_W + 8, CARD_H + 8),
Phaser.Geom.Rectangle.Contains
)
hl.input.cursor = 'pointer'
hl.on('pointerdown', () => this._onTargetSlotClicked(player.teamIndex))
this._targetHighlights = this._targetHighlights ?? []
this._targetHighlights.push(hl)
}
})
if (!enable) {
(this._targetHighlights ?? []).forEach(hl => hl.destroy())
this._targetHighlights = []
}
}
/**
* Le ciblage transite par `teamIndex` (identifiant public, stable et
* unique par joueur pour toute la partie) plutôt que par `socketId`
* (jamais exposé au client — donnée serveur interne, voir
* Room.getPlayersInfo). Le serveur résout targetTeamIndex -> le vrai
* joueur ciblé lui-même (voir Room.playEventCard / _resolvePendingEventPlays).
*/
_onTargetSlotClicked(targetTeamIndex) {
if (!this._targetingState || targetTeamIndex === null || targetTeamIndex === undefined) return
const { card, keys, instructions, collected } = this._targetingState
const currentKey = keys[0]
collected[currentKey] = targetTeamIndex
keys.shift()
if (keys.length === 0) {
this._highlightOpponentSlots(false)
const targets = this._targetingState.collected
this._targetingState = null
this._confirmPlayEvent(card, targets)
} else {
this.phaseText.setText(instructions[keys[0]])
}
}
/** Annule une séquence de ciblage en cours (ex: changement de phase serveur). */
_cancelTargeting() {
if (!this._targetingState) return
this._highlightOpponentSlots(false)
this._targetingState = null
}
_confirmPlayEvent(card, targets) {
card.select()
this.selectedEventCard = card
this.socket.emit('play-event', {
roomId: this.roomId,
eventId: card.eventData.id,
targets
})
}
// ─── Main du joueur ──────────────────────────────────────────────────────────
/**
@@ -850,6 +1110,15 @@ export class GameScene extends Phaser.Scene {
this._buildMyBuildingTokens()
}
// Reconstruire ma main d'événements uniquement si elle a réellement
// changé (pioche, ou carte jouée/défaussée après résolution).
const myEventHandKey = JSON.stringify(state.myEventHand ?? [])
if (this._lastEventHandKey !== myEventHandKey) {
this._lastEventHandKey = myEventHandKey
this._buildEventHand(state.myEventHand ?? [])
}
this._refreshEventDrawButtonState(me?.gauges, (state.myEventHand ?? []).length, !!state.hasPendingEventDraw)
if (state.phase === 'selecting') {
this._canPlay = true
this.resolveObjects.forEach(o => o.destroy())
@@ -893,6 +1162,10 @@ export class GameScene extends Phaser.Scene {
if (state.phase === 'resolve' || state.phase === 'end') {
this._canPlay = false
this._deactivateBtn()
// Sécurité : une séquence de ciblage en cours n'a plus de sens une
// fois le tour résolu (ex: l'utilisateur a laissé un ciblage
// inachevé et le tour a été résolu par les autres joueurs entre-temps).
this._cancelTargeting()
}
})
@@ -902,7 +1175,7 @@ export class GameScene extends Phaser.Scene {
}
})
this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings }) => {
this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings, eventResults }) => {
this.phaseText.setText('Résolution du tour...')
this.readyText.setText('')
this._showPlayedCards(playedCards, gains)
@@ -911,8 +1184,10 @@ export class GameScene extends Phaser.Scene {
...(boughtItems ?? []).map(b => `${b.playerName} → ${b.itemName}`),
...(boughtBuildings ?? []).map(b => `${b.playerName} → ${b.buildingName}`)
]
if (boughtLabels.length > 0) {
this.readyText.setText(`🛒 ${boughtLabels.join(' | ')}`)
const eventLabels = (eventResults ?? []).map(e => e.summary)
const allLabels = [...boughtLabels, ...eventLabels]
if (allLabels.length > 0) {
this.readyText.setText(`🛒🎴 ${allLabels.join(' | ')}`)
}
})