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:
1 parent
9139989fa7
commit
1726f8f96b
17 files changed
+1952
-16
No files matched your search
@@ -3,6 +3,7 @@ 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'
|
||||
import { BUILDINGS } from '../buildings'
|
||||
import { EVENTS } from '../events'
|
||||
|
||||
const CARD_IMAGES = [
|
||||
{ id: 'queen', file: 'reine.png' },
|
||||
@@ -58,12 +59,17 @@ export class BootScene extends Phaser.Scene {
|
||||
BUILDINGS.forEach(({ id, image }) => {
|
||||
this.load.image(`raw-building-${id}`, `/buildings/${image}`)
|
||||
})
|
||||
|
||||
// Charger les images brutes des cartes Événement
|
||||
EVENTS.forEach(({ id, image }) => {
|
||||
this.load.image(`raw-event-${id}`, `/events/${image}`)
|
||||
})
|
||||
}
|
||||
|
||||
create() {
|
||||
// Pour chaque personnage, générer une texture clippée via Canvas 2D
|
||||
CARD_IMAGES.forEach(({ id }) => {
|
||||
this._generateClippedTexture(id)
|
||||
this._generateClippedTexture(`raw-${id}`, `card-${id}`)
|
||||
})
|
||||
|
||||
// Pour chaque objet, générer une texture portrait (zone haute uniquement,
|
||||
@@ -77,18 +83,23 @@ export class BootScene extends Phaser.Scene {
|
||||
this._generatePortraitTexture(`raw-building-${id}`, `building-${id}`)
|
||||
})
|
||||
|
||||
// Pour chaque carte Événement, texture plein cadre (mêmes proportions
|
||||
// que les cartes personnage) — la mécanique diffère mais le rendu visuel
|
||||
// se veut cohérent avec le style épuré de CardObject.
|
||||
EVENTS.forEach(({ id }) => {
|
||||
this._generateClippedTexture(`raw-event-${id}`, `event-${id}`)
|
||||
})
|
||||
|
||||
this.scene.start('GameScene')
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une texture `card-{id}` de dimensions CARD_W × CARD_H
|
||||
* avec l'image clippée en rectangle arrondi via Canvas 2D natif.
|
||||
* Pas de masque Phaser, pas de RenderTexture — juste du canvas.
|
||||
* Crée une texture `outKey` de dimensions CARD_W × CARD_H avec l'image
|
||||
* clippée en rectangle arrondi via Canvas 2D natif (pas de masque Phaser,
|
||||
* pas de RenderTexture). Réutilisée pour les personnages ET les cartes
|
||||
* Événement, qui partagent le même format "portrait plein cadre".
|
||||
*/
|
||||
_generateClippedTexture(id) {
|
||||
const rawKey = `raw-${id}`
|
||||
const outKey = `card-${id}`
|
||||
|
||||
_generateClippedTexture(rawKey, outKey) {
|
||||
if (!this.textures.exists(rawKey)) return
|
||||
if (this.textures.exists(outKey)) return
|
||||
|
||||
|
||||
@@ -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(' | ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user