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
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Regnum - Définition des 6 cartes Événement (miroir du serveur, pour
|
||||
* l'affichage) — voir server/src/game/events.js pour la logique et le coût
|
||||
* exact (1 point dans chacune des 4 jauges, identique pour tous).
|
||||
*
|
||||
* needsTargetOnDraw / needsTargetOnPlay pilotent l'UI de ciblage côté
|
||||
* client : au tirage pour Accord royal (ciblage indépendant de tout objet
|
||||
* possédé), au moment de jouer la carte pour Épidémie (la variante Trousse
|
||||
* n'est déterminée qu'à cet instant).
|
||||
*/
|
||||
|
||||
export const EVENTS = [
|
||||
{
|
||||
id: 'bountiful_harvest',
|
||||
name: 'Récolte exceptionnelle',
|
||||
image: 'recolte.png',
|
||||
negative: false,
|
||||
description: 'Gagnez 2 Nourriture. Synergie — Charrue : si vous possédez la Charrue, gagnez 1 Nourriture supplémentaire.'
|
||||
},
|
||||
{
|
||||
id: 'epidemic',
|
||||
name: 'Épidémie',
|
||||
image: 'epidemie.png',
|
||||
negative: true,
|
||||
needsTargetOnPlay: true,
|
||||
description: 'Effet de base : chaque joueur perd 1 Santé. Avec la Trousse du Médecin : un adversaire choisi perd 2 Santé à la place. Avec l\'Église : tous les adversaires perdent 2 Santé à la place.'
|
||||
},
|
||||
{
|
||||
id: 'troop_levy',
|
||||
name: 'Levée des troupes',
|
||||
image: 'troupe.png',
|
||||
negative: false,
|
||||
description: 'Gagnez 2 Guerre et perdez 1 Diplomatie. Synergie — Trébuchet : si vous possédez le Trébuchet, gagnez 2 Guerre supplémentaires (soit 4 Guerre au total).'
|
||||
},
|
||||
{
|
||||
id: 'royal_accord',
|
||||
name: 'Accord royal',
|
||||
image: 'accord.png',
|
||||
negative: false,
|
||||
needsTargetOnDraw: true,
|
||||
description: 'Gagnez 1 Diplomatie. Choisissez un autre joueur : il gagne 1 Diplomatie. Choisissez un autre joueur : il perd 1 Diplomatie (peuvent être différents). Synergie — Sceau Royal : si vous possédez le Sceau, gagnez 1 Diplomatie supplémentaire.'
|
||||
},
|
||||
{
|
||||
id: 'plot_uncovered',
|
||||
name: 'Complot découvert',
|
||||
image: 'complot.png',
|
||||
negative: false,
|
||||
description: 'Jusqu\'à la fin du tour, annule pour vous les effets négatifs des autres cartes Événement jouées ce tour.'
|
||||
},
|
||||
{
|
||||
id: 'castle_feast',
|
||||
name: 'Grande fête au château',
|
||||
image: 'fete.png',
|
||||
negative: false,
|
||||
description: 'Gagnez 1 Nourriture et 1 Diplomatie. Synergie — Château Royal : si vous possédez le Château Royal, piochez 1 carte Événement supplémentaire.'
|
||||
}
|
||||
]
|
||||
|
||||
export function getEventById(id) {
|
||||
return EVENTS.find(e => e.id === id) ?? null
|
||||
}
|
||||
|
||||
/** Coût d'une pioche d'événement : 1 point dans chacune des 4 jauges. */
|
||||
export const DRAW_COST = { food: 1, war: 1, health: 1, diplo: 1 }
|
||||
@@ -0,0 +1,125 @@
|
||||
import Phaser from 'phaser'
|
||||
import { CARD_W, CARD_H, RADIUS } from './cardLayout'
|
||||
|
||||
/**
|
||||
* Carte Événement dans la "seconde main" du joueur (max 2 cartes, piochées
|
||||
* séparément des cartes personnage). Reprend le style épuré de CardObject
|
||||
* (portrait plein cadre, nom en bas) avec un liseré doré distinctif (au lieu
|
||||
* de la couleur d'équipe, non pertinente ici) et un badge rouge "!" pour les
|
||||
* événements à effet négatif (Épidémie), afin que le joueur identifie vite
|
||||
* les risques avant de choisir de la jouer.
|
||||
*
|
||||
* Contrairement à CardObject, la sélection ici signifie "je choisis de jouer
|
||||
* CETTE carte événement ce tour" (au maximum une par tour) — gérée par
|
||||
* GameScene, qui peut aussi déclencher une UI de ciblage supplémentaire
|
||||
* avant de confirmer réellement l'émission au serveur.
|
||||
*/
|
||||
export class EventCardObject extends Phaser.GameObjects.Container {
|
||||
constructor(scene, x, y, eventData) {
|
||||
super(scene, x, y)
|
||||
this.eventData = eventData
|
||||
this._selected = false
|
||||
this._baseY = y
|
||||
this._build()
|
||||
scene.add.existing(this)
|
||||
}
|
||||
|
||||
_build() {
|
||||
const textureKey = `event-${this.eventData.id}`
|
||||
const hasTexture = this.scene.textures.exists(textureKey)
|
||||
|
||||
// ── 1. Ombre légère ─────────────────────────────────────────────────────
|
||||
const shadow = this.scene.add.graphics()
|
||||
shadow.fillStyle(0x000000, 0.4)
|
||||
shadow.fillRoundedRect(-CARD_W / 2 + 3, -CARD_H / 2 + 3, CARD_W, CARD_H, RADIUS)
|
||||
this.add(shadow)
|
||||
|
||||
// ── 2. Image portrait plein cadre ─────────────────────────────────────────
|
||||
if (hasTexture) {
|
||||
const img = this.scene.add.image(0, 0, textureKey)
|
||||
this.add(img)
|
||||
} else {
|
||||
const bg = this.scene.add.graphics()
|
||||
bg.fillStyle(0x2a1810, 1)
|
||||
bg.fillRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS)
|
||||
this.add(bg)
|
||||
const initial = this.scene.add.text(0, -10, this.eventData.name[0], {
|
||||
fontSize: '70px', fontFamily: 'Georgia', color: '#ffffff'
|
||||
}).setOrigin(0.5).setAlpha(0.15)
|
||||
this.add(initial)
|
||||
}
|
||||
|
||||
// ── 3. Liseré doré (distinct de la couleur d'équipe des personnages) ──────
|
||||
const border = this.scene.add.graphics()
|
||||
border.lineStyle(1.5, 0xd4a732, 0.9)
|
||||
border.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, RADIUS)
|
||||
this.add(border)
|
||||
|
||||
// ── 4. Nom en bas ──────────────────────────────────────────────────────────
|
||||
this.add(this.scene.add.text(
|
||||
0, CARD_H / 2 - 14,
|
||||
this.eventData.name, {
|
||||
fontSize: '12px',
|
||||
fontFamily: 'Georgia, serif',
|
||||
fontStyle: 'bold',
|
||||
color: '#f5e6c8',
|
||||
stroke: '#000000',
|
||||
strokeThickness: 3,
|
||||
align: 'center',
|
||||
wordWrap: { width: CARD_W - 12 }
|
||||
}
|
||||
).setOrigin(0.5, 0.5))
|
||||
|
||||
// ── 5. Badge "effet négatif" (coin haut-droit) ────────────────────────────
|
||||
if (this.eventData.negative) {
|
||||
const bx = CARD_W / 2 - 16, by = -CARD_H / 2 + 16
|
||||
const ring = this.scene.add.graphics()
|
||||
ring.fillStyle(0x1a0a00, 0.95)
|
||||
ring.fillCircle(bx, by, 12)
|
||||
this.add(ring)
|
||||
const disk = this.scene.add.graphics()
|
||||
disk.fillStyle(0xcc2222, 1)
|
||||
disk.fillCircle(bx, by, 10)
|
||||
this.add(disk)
|
||||
this.add(this.scene.add.text(bx, by, '!', {
|
||||
fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff'
|
||||
}).setOrigin(0.5))
|
||||
}
|
||||
|
||||
// ── 6. Interactivité ──────────────────────────────────────────────────────
|
||||
this.setSize(CARD_W, CARD_H)
|
||||
this.setInteractive({ cursor: 'pointer' })
|
||||
this.on('pointerover', this._onHover, this)
|
||||
this.on('pointerout', this._onOut, this)
|
||||
this.on('pointerdown', this._onDown, this)
|
||||
}
|
||||
|
||||
_onHover() {
|
||||
if (this._selected) return
|
||||
this.scene.tweens.add({ targets: this, y: this._baseY - 18, duration: 120, ease: 'Power2' })
|
||||
this.setDepth(10)
|
||||
}
|
||||
|
||||
_onOut() {
|
||||
if (this._selected) return
|
||||
this.scene.tweens.add({ targets: this, y: this._baseY, duration: 120, ease: 'Power2' })
|
||||
this.setDepth(0)
|
||||
}
|
||||
|
||||
_onDown() {
|
||||
if (this._selected) { this.deselect() }
|
||||
else { this.select(); this.emit('select', this) }
|
||||
}
|
||||
|
||||
select() {
|
||||
this._selected = true
|
||||
this.setDepth(20)
|
||||
this.scene.tweens.add({ targets: this, y: this._baseY - 28, duration: 150, ease: 'Back.Out' })
|
||||
}
|
||||
|
||||
deselect() {
|
||||
this._selected = false
|
||||
this.setDepth(0)
|
||||
this.scene.tweens.add({ targets: this, y: this._baseY, duration: 150, ease: 'Power2' })
|
||||
}
|
||||
}
|
||||
@@ -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