Files
regnum/client/src/game/scenes/BootScene.js
T
Kevin L'Hereec 1726f8f96b 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.
2026-08-21 17:05:50 +02:00

213 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}