- 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.
213 lines
7.1 KiB
JavaScript
213 lines
7.1 KiB
JavaScript
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'
|
||
import { BUILDINGS } from '../buildings'
|
||
import { EVENTS } from '../events'
|
||
|
||
const CARD_IMAGES = [
|
||
{ id: 'queen', file: 'reine.png' },
|
||
{ id: 'prince', file: 'prince.png' },
|
||
{ id: 'butcher', file: 'boucher.png' },
|
||
{ id: 'doctor', file: 'medecin.png' },
|
||
{ id: 'apothecary', file: 'apoticaire.png' },
|
||
{ id: 'jester', file: 'fou_du_roi.png' },
|
||
{ id: 'assassin', file: 'assassin.png' },
|
||
{ id: 'peasant', file: 'paysan.png' },
|
||
{ id: 'executioner', file: 'bourreau.png' },
|
||
{ id: 'knight', file: 'chevalier.png' },
|
||
{ id: 'merchant', file: 'marchand.png' },
|
||
{ id: 'bishop', file: 'eveque.png' }
|
||
]
|
||
|
||
export class BootScene extends Phaser.Scene {
|
||
constructor() {
|
||
super('BootScene')
|
||
}
|
||
|
||
preload() {
|
||
// Barre de chargement
|
||
const { width, height } = this.scale
|
||
const barBg = this.add.graphics()
|
||
const barFill = this.add.graphics()
|
||
|
||
barBg.fillStyle(0x2a1500)
|
||
barBg.fillRect(width / 2 - 150, height / 2 - 10, 300, 20)
|
||
|
||
this.load.on('progress', (v) => {
|
||
barFill.clear()
|
||
barFill.fillStyle(0xd4a732)
|
||
barFill.fillRect(width / 2 - 150, height / 2 - 10, 300 * v, 20)
|
||
})
|
||
|
||
this.load.on('complete', () => {
|
||
barBg.destroy()
|
||
barFill.destroy()
|
||
})
|
||
|
||
// 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}`)
|
||
})
|
||
|
||
// Charger les images brutes des bâtiments
|
||
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(`raw-${id}`, `card-${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._generatePortraitTexture(`raw-item-${id}`, `item-${id}`)
|
||
})
|
||
|
||
// Pour chaque bâtiment, même traitement (même layout de carte que les objets)
|
||
BUILDINGS.forEach(({ id }) => {
|
||
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 `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(rawKey, outKey) {
|
||
if (!this.textures.exists(rawKey)) return
|
||
if (this.textures.exists(outKey)) return
|
||
|
||
const srcImage = this.textures.get(rawKey).getSourceImage()
|
||
|
||
// Créer un canvas offscreen
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = CARD_W
|
||
canvas.height = CARD_H
|
||
const ctx = canvas.getContext('2d')
|
||
|
||
// Chemin arrondi (clip)
|
||
ctx.beginPath()
|
||
ctx.moveTo(RADIUS, 0)
|
||
ctx.lineTo(CARD_W - RADIUS, 0)
|
||
ctx.quadraticCurveTo(CARD_W, 0, CARD_W, RADIUS)
|
||
ctx.lineTo(CARD_W, CARD_H - RADIUS)
|
||
ctx.quadraticCurveTo(CARD_W, CARD_H, CARD_W - RADIUS, CARD_H)
|
||
ctx.lineTo(RADIUS, CARD_H)
|
||
ctx.quadraticCurveTo(0, CARD_H, 0, CARD_H - RADIUS)
|
||
ctx.lineTo(0, RADIUS)
|
||
ctx.quadraticCurveTo(0, 0, RADIUS, 0)
|
||
ctx.closePath()
|
||
ctx.clip()
|
||
|
||
// Dessiner l'image en mode "cover" (ratio conservé, centré)
|
||
const scaleX = CARD_W / srcImage.width
|
||
const scaleY = CARD_H / srcImage.height
|
||
const scale = Math.max(scaleX, scaleY)
|
||
const dw = srcImage.width * scale
|
||
const dh = srcImage.height * scale
|
||
const ox = (CARD_W - dw) / 2
|
||
const oy = (CARD_H - dh) / 2
|
||
|
||
ctx.drawImage(srcImage, ox, oy, dw, dh)
|
||
|
||
// Dégradé haut (lisibilité badges)
|
||
const gTop = ctx.createLinearGradient(0, 0, 0, 45)
|
||
gTop.addColorStop(0, 'rgba(0,0,0,0.75)')
|
||
gTop.addColorStop(1, 'rgba(0,0,0,0)')
|
||
ctx.fillStyle = gTop
|
||
ctx.fillRect(0, 0, CARD_W, 45)
|
||
|
||
// Dégradé bas (lisibilité titre)
|
||
const gBot = ctx.createLinearGradient(0, CARD_H - 40, 0, CARD_H)
|
||
gBot.addColorStop(0, 'rgba(0,0,0,0)')
|
||
gBot.addColorStop(1, 'rgba(0,0,0,0.85)')
|
||
ctx.fillStyle = gBot
|
||
ctx.fillRect(0, CARD_H - 40, CARD_W, 40)
|
||
|
||
// Enregistrer comme texture Phaser
|
||
this.textures.addCanvas(outKey, canvas)
|
||
}
|
||
|
||
/**
|
||
* Crée une texture `outKey` de dimensions ITEM_CARD_W × ITEM_IMAGE_H
|
||
* (uniquement la zone portrait en haut d'une carte objet/bâtiment, coins
|
||
* arrondis en haut seulement — le bas de la carte est une zone de texte
|
||
* gérée par ItemBoardToken, pas par cette texture). Réutilisée pour les
|
||
* objets ET les bâtiments, qui partagent le même layout de carte.
|
||
*/
|
||
_generatePortraitTexture(rawKey, outKey) {
|
||
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)
|
||
}
|
||
}
|