feat: système d'objets achetables (boutique + effets permanents) et refonte visuelle
- Nouveaux personnages illustrés (12 PNG remplacés dans client/public/cards) - 4 objets achetables (Trébuchet, Charrue, Trousse du Médecin, Sceau Royal) avec effets permanents (double_gain, guaranteed_gain, copy_best_in_game), stock unique par partie, priorité d'achat tournante entre joueurs - Boutique en colonne à gauche du plateau : icônes compactes, carte détaillée agrandie au survol (taille découplée du displayScale du jeton), bouton ACHETER actif/grisé selon la jauge du joueur, retrait immédiat de la colonne une fois l'objet acheté (les autres remontent pour combler) - Objets achetés affichés "posés sur la table" : à côté de la main pour soi, sous le slot de jeu pour les adversaires - Icône du premier joueur (priorité d'achat) dans le HUD Vue - Refonte des cartes personnage (CardObject) : portrait plein cadre épuré, valeurs d'attribut discrètes aux 4 coins - Fix removePlayer : nettoyage de pendingBuys/pendingPlays et recalage de firstPlayerIndex à la déconnexion d'un joueur - 77 tests unitaires serveur (node:test) : cards, items, Room, RoomManager - README mis à jour avec les règles complètes du jeu - .gitignore : exclusion de .idea/ et .serena/ (artefacts d'outils locaux)
This commit is contained in:
1 parent
da06ecaf2d
commit
6a1c800253
37 files changed
+3558
-188
No files matched your search
@@ -1,4 +1,7 @@
|
||||
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'
|
||||
|
||||
const CARD_IMAGES = [
|
||||
{ id: 'queen', file: 'reine.png' },
|
||||
@@ -15,10 +18,6 @@ const CARD_IMAGES = [
|
||||
{ id: 'bishop', file: 'eveque.png' }
|
||||
]
|
||||
|
||||
const CARD_W = 130
|
||||
const CARD_H = 190
|
||||
const RADIUS = 10
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('BootScene')
|
||||
@@ -44,10 +43,15 @@ export class BootScene extends Phaser.Scene {
|
||||
barFill.destroy()
|
||||
})
|
||||
|
||||
// Charger les images brutes
|
||||
// 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}`)
|
||||
})
|
||||
}
|
||||
|
||||
create() {
|
||||
@@ -56,6 +60,12 @@ export class BootScene extends Phaser.Scene {
|
||||
this._generateClippedTexture(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._generateItemTexture(id)
|
||||
})
|
||||
|
||||
this.scene.start('GameScene')
|
||||
}
|
||||
|
||||
@@ -121,4 +131,62 @@ export class BootScene extends Phaser.Scene {
|
||||
// Enregistrer comme texture Phaser
|
||||
this.textures.addCanvas(outKey, canvas)
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une texture `item-{id}` de dimensions ITEM_CARD_W × ITEM_IMAGE_H
|
||||
* (uniquement la zone portrait en haut de la carte objet, coins arrondis
|
||||
* en haut seulement — le bas de la carte est une zone de texte gérée par
|
||||
* ItemObject, pas par cette texture).
|
||||
*/
|
||||
_generateItemTexture(id) {
|
||||
const rawKey = `raw-item-${id}`
|
||||
const outKey = `item-${id}`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
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 { ITEMS, getItemById } from '../items'
|
||||
|
||||
// Marge depuis le bas du canvas pour éviter la barre Windows
|
||||
const BOTTOM_MARGIN = 60
|
||||
|
||||
// Espacement max entre cartes en main et hauteur de la zone de main,
|
||||
// dérivés de la taille réelle des cartes (cardLayout) pour rester cohérents
|
||||
// si la taille des cartes change.
|
||||
const HAND_SPACING_MAX = CARD_W + 35
|
||||
const HAND_Y_OFFSET = CARD_H / 2 + 15
|
||||
|
||||
// Couleurs équipes (synchronisé avec le serveur)
|
||||
const TEAM_COLORS = {
|
||||
0: 0xcc2200,
|
||||
@@ -12,7 +21,7 @@ const TEAM_COLORS = {
|
||||
3: 0xb8860b
|
||||
}
|
||||
|
||||
// Couleurs attributs
|
||||
// Couleurs attributs (hex, pour les Graphics)
|
||||
const ATTR_COLORS = {
|
||||
food: 0xf0c030,
|
||||
war: 0xdd2222,
|
||||
@@ -20,6 +29,14 @@ const ATTR_COLORS = {
|
||||
diplo: 0x3399ee
|
||||
}
|
||||
|
||||
// Couleurs attributs (CSS, pour les Text — cohérent avec CardObject)
|
||||
const ATTR_COLORS_CSS = {
|
||||
food: '#f0c030',
|
||||
war: '#ff5555',
|
||||
health: '#4ade80',
|
||||
diplo: '#60a5fa'
|
||||
}
|
||||
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
@@ -28,6 +45,15 @@ export class GameScene extends Phaser.Scene {
|
||||
this.selectedCard = null
|
||||
this.resolveObjects = []
|
||||
this._canPlay = false // true uniquement quand c'est la phase 'selecting' locale
|
||||
this._lastHandTurn = null // dernier tour pour lequel la main a été (re)construite
|
||||
this._lastRoundResult = null // { playedCards, gains } affichés actuellement (phase resolve)
|
||||
this._resizeTimer = null // debounce du redimensionnement du canvas
|
||||
|
||||
// ── Objets sur le plateau (jetons permanents, pas de panneau modal) ────────
|
||||
this.itemTokens = [] // ItemBoardToken (boutique) actuellement affichés
|
||||
this._shopState = null // { myItems, pendingBuyItemId, availableItems, firstPlayerName }
|
||||
this.myItemTokens = [] // ItemBoardToken des objets que JE possède, à côté de ma main
|
||||
this.opponentItemTokens = [] // ItemBoardToken des objets possédés par les autres joueurs
|
||||
}
|
||||
|
||||
create() {
|
||||
@@ -37,21 +63,62 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
this._buildTable()
|
||||
this._buildPlayButton()
|
||||
this._buildItemTokens()
|
||||
this._setupSocketListeners()
|
||||
|
||||
// Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue
|
||||
// grandit après réception des vraies données de partie (jauges des joueurs).
|
||||
// Sans repositionnement, les éléments d'UI (dont le bouton de confirmation)
|
||||
// restent calés sur l'ancienne hauteur et finissent hors de la zone cliquable.
|
||||
this.scale.on('resize', this._onResizeDebounced, this)
|
||||
|
||||
// IMPORTANT : Phaser n'appelle jamais une méthode `shutdown()` définie sur la
|
||||
// Scene elle-même — il faut s'abonner explicitement à l'événement du cycle de
|
||||
// vie pour libérer les listeners, sinon ils s'accumulent à chaque (re)création
|
||||
// de la scène (navigation vers une nouvelle partie, restart, etc.).
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, this._onShutdown, this)
|
||||
this.events.once(Phaser.Scenes.Events.DESTROY, this._onShutdown, this)
|
||||
|
||||
this.socket.emit('request-state', { roomId: this.roomId })
|
||||
}
|
||||
|
||||
_onShutdown() {
|
||||
this.scale.off('resize', this._onResizeDebounced, this)
|
||||
if (this._resizeTimer) {
|
||||
clearTimeout(this._resizeTimer)
|
||||
this._resizeTimer = null
|
||||
}
|
||||
if (this._errorTimer) {
|
||||
clearTimeout(this._errorTimer)
|
||||
this._errorTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regroupe (debounce) les événements resize rapprochés : Scale.RESIZE peut
|
||||
* émettre plusieurs événements en rafale (redimensionnement de fenêtre en
|
||||
* direct, réflows successifs du HUD) ; sans regroupement, chaque événement
|
||||
* déclencherait une reconstruction destructive complète (slots, légende)
|
||||
* et de nouveaux tweens sur la main, ce qui dégraderait les performances.
|
||||
*/
|
||||
_onResizeDebounced(gameSize) {
|
||||
if (this._resizeTimer) clearTimeout(this._resizeTimer)
|
||||
this._resizeTimer = setTimeout(() => {
|
||||
this._resizeTimer = null
|
||||
this._onResize(gameSize)
|
||||
}, 120)
|
||||
}
|
||||
|
||||
// ─── Construction du plateau ─────────────────────────────────────────────────
|
||||
|
||||
_buildTable() {
|
||||
const { width, height } = this.scale
|
||||
|
||||
// Fond parchemin
|
||||
this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500)
|
||||
this.bgRect = this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500)
|
||||
|
||||
// Zone de jeu centrale
|
||||
this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7)
|
||||
this.zoneRect = this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7)
|
||||
.setStrokeStyle(1, 0xd4a73230)
|
||||
|
||||
// Titre du tour
|
||||
@@ -73,45 +140,121 @@ export class GameScene extends Phaser.Scene {
|
||||
this.slotObjects = [] // pour pouvoir les détruire/recréer
|
||||
|
||||
// Légende attributs (coin en bas à droite, au-dessus de la marge Windows)
|
||||
this.legendObjects = []
|
||||
this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositionne tous les éléments dépendants de la taille du canvas.
|
||||
* Nécessaire car le canvas Phaser (mode Scale.RESIZE) se redimensionne
|
||||
* une fois que le HUD Vue grandit (jauges des joueurs affichées), ce qui
|
||||
* décale la zone visible sans que les éléments d'UI ne soient recalés —
|
||||
* rendant notamment le bouton de confirmation hors-zone cliquable.
|
||||
*/
|
||||
_onResize(gameSize) {
|
||||
const width = gameSize.width
|
||||
const height = gameSize.height
|
||||
|
||||
// Fond + zone centrale
|
||||
if (this.bgRect) {
|
||||
this.bgRect.setPosition(width / 2, height / 2)
|
||||
this.bgRect.setSize(width, height)
|
||||
}
|
||||
if (this.zoneRect) {
|
||||
this.zoneRect.setPosition(width / 2, height / 2 - 60)
|
||||
this.zoneRect.setSize(width * 0.7, height * 0.38)
|
||||
}
|
||||
|
||||
// Textes du haut
|
||||
if (this.turnText) this.turnText.setPosition(width / 2, 18)
|
||||
if (this.phaseText) this.phaseText.setPosition(width / 2, 40)
|
||||
|
||||
// Légende attributs
|
||||
this._buildAttrLegend(width - 10, height - BOTTOM_MARGIN - 10)
|
||||
|
||||
// Slots de jeu. Reconstruire les slots est destructif et invaliderait les
|
||||
// coordonnées déjà utilisées par les cartes révélées à l'écran (phase
|
||||
// 'resolve'/'end') si on ne les redessine pas aussi — donc on les
|
||||
// reconstruit ensemble pour rester synchronisés.
|
||||
if (this.playSlots.length) {
|
||||
this._buildSlots(this.playSlots.length)
|
||||
if (this._lastRoundResult) {
|
||||
this._showPlayedCards(this._lastRoundResult.playedCards, this._lastRoundResult.gains)
|
||||
}
|
||||
}
|
||||
|
||||
// Bouton de confirmation + compteur
|
||||
this._layoutPlayButton()
|
||||
|
||||
// Bande de jetons d'objet en haut du plateau
|
||||
this._layoutItemTokens()
|
||||
|
||||
// Main du joueur (repositionnement simple, sans reconstruction)
|
||||
this._repositionHand()
|
||||
}
|
||||
|
||||
_buildSlots(playerCount) {
|
||||
const { width, height } = this.scale
|
||||
|
||||
// Détruire les anciens slots
|
||||
// Détruire les anciens slots (et les mini-cartes objets des adversaires)
|
||||
this.slotObjects.forEach(o => o.destroy())
|
||||
this.slotObjects = []
|
||||
this.playSlots = []
|
||||
this.opponentItemTokens.forEach(t => t.destroy())
|
||||
this.opponentItemTokens = []
|
||||
|
||||
const slotY = height / 2 - 60
|
||||
const spacing = 140
|
||||
const spacing = CARD_W + 30
|
||||
const totalW = playerCount * spacing
|
||||
const startX = width / 2 - totalW / 2 + spacing / 2
|
||||
|
||||
for (let i = 0; i < playerCount; i++) {
|
||||
const x = startX + i * spacing
|
||||
const player = this.store.players[i]
|
||||
|
||||
const g = this.add.graphics()
|
||||
g.lineStyle(1, 0xd4a73240)
|
||||
g.strokeRoundedRect(-65, -95, 130, 190, 8)
|
||||
g.strokeRoundedRect(-CARD_W / 2, -CARD_H / 2, CARD_W, CARD_H, 8)
|
||||
g.x = x
|
||||
g.y = slotY
|
||||
this.slotObjects.push(g)
|
||||
|
||||
const label = this.add.text(x, slotY + 108,
|
||||
this.store.players[i]?.name ?? `J${i + 1}`, {
|
||||
player?.name ?? `J${i + 1}`, {
|
||||
fontSize: '11px',
|
||||
fontFamily: 'Georgia',
|
||||
color: '#4a3020'
|
||||
}).setOrigin(0.5)
|
||||
this.slotObjects.push(label)
|
||||
|
||||
// Mini-cartes objets "posées devant" cet adversaire, juste sous son
|
||||
// slot de jeu — comme sur ma propre table à côté de ma main. On ne le
|
||||
// fait pas pour MON propre slot : mes objets sont déjà affichés à côté
|
||||
// de ma main (plus pertinent puisque je vois ma main, pas mon slot).
|
||||
const isMe = player?.teamIndex === this.store.myTeamIndex
|
||||
if (!isMe) {
|
||||
const ownedItems = player?.items ?? []
|
||||
const tokenGap = ICON_SIZE * 0.55 + 6
|
||||
const startTokenX = x - (ownedItems.length - 1) * tokenGap / 2
|
||||
ownedItems.forEach((itemId, k) => {
|
||||
const item = getItemById(itemId)
|
||||
if (!item) return
|
||||
// displayScale=0.55 réduit uniquement le jeton compact ; la carte
|
||||
// détaillée au survol reste à sa taille normale (voir ItemBoardToken).
|
||||
const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY + 128, item, 'owned', false, 0.55)
|
||||
this.opponentItemTokens.push(token)
|
||||
})
|
||||
}
|
||||
|
||||
this.playSlots.push({ x, y: slotY })
|
||||
}
|
||||
}
|
||||
|
||||
_buildAttrLegend(x, y) {
|
||||
// Détruire l'ancienne légende avant reconstruction (appelé aussi au resize)
|
||||
if (this.legendObjects) this.legendObjects.forEach(o => o.destroy())
|
||||
this.legendObjects = []
|
||||
|
||||
const entries = [
|
||||
{ attr: 'food', label: '🟡 Nourriture — haut gauche' },
|
||||
{ attr: 'war', label: '🔴 Guerre — haut droite' },
|
||||
@@ -120,17 +263,24 @@ export class GameScene extends Phaser.Scene {
|
||||
]
|
||||
|
||||
entries.forEach((e, i) => {
|
||||
this.add.text(x, y - i * 16, e.label, {
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Georgia',
|
||||
color: '#6a5040'
|
||||
}).setOrigin(1, 1)
|
||||
this.legendObjects.push(
|
||||
this.add.text(x, y - i * 16, e.label, {
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Georgia',
|
||||
color: '#6a5040'
|
||||
}).setOrigin(1, 1)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Position Y (unique source de vérité) du bouton de confirmation et de son fond. */
|
||||
_playButtonY() {
|
||||
return this.scale.height - BOTTOM_MARGIN - 20
|
||||
}
|
||||
|
||||
_buildPlayButton() {
|
||||
const { width, height } = this.scale
|
||||
const btnY = height - BOTTOM_MARGIN - 20
|
||||
const { width } = this.scale
|
||||
const btnY = this._playButtonY()
|
||||
|
||||
// Fond du bouton (rectangle arrondi via Graphics)
|
||||
this.playBtnBg = this.add.graphics()
|
||||
@@ -160,9 +310,21 @@ export class GameScene extends Phaser.Scene {
|
||||
}).setOrigin(0.5).setDepth(100)
|
||||
}
|
||||
|
||||
/** Recale le bouton de confirmation et son compteur sur la taille actuelle du canvas. */
|
||||
_layoutPlayButton() {
|
||||
const { width } = this.scale
|
||||
const btnY = this._playButtonY()
|
||||
|
||||
if (this.playBtn) this.playBtn.setPosition(width / 2, btnY)
|
||||
if (this.readyText) this.readyText.setPosition(width / 2, btnY + 28)
|
||||
|
||||
// Redessiner le fond du bouton (dépend de width/height) en conservant son état actif/inactif
|
||||
this._drawBtnBg(!!this._pulseTween)
|
||||
}
|
||||
|
||||
_drawBtnBg(active) {
|
||||
const { width, height } = this.scale
|
||||
const btnY = height - BOTTOM_MARGIN - 20
|
||||
const { width } = this.scale
|
||||
const btnY = this._playButtonY()
|
||||
const btnW = 280
|
||||
const btnH = 42
|
||||
this.playBtnBg.clear()
|
||||
@@ -201,27 +363,174 @@ export class GameScene extends Phaser.Scene {
|
||||
if (this._pulseTween) { this._pulseTween.stop(); this._pulseTween = null }
|
||||
}
|
||||
|
||||
// ─── Objets sur le plateau (jetons permanents, agrandissement au survol) ────
|
||||
|
||||
/**
|
||||
* Colonne verticale sur le bord GAUCHE du plateau (au lieu d'une bande
|
||||
* horizontale en haut). La bande horizontale précédente plaçait les jetons
|
||||
* trop près du bord supérieur du canvas : la carte agrandie au survol
|
||||
* (hauteur ~172px, centrée sur le jeton) débordait au-dessus de y=0 et se
|
||||
* retrouvait coupée/invisible sous la barre HUD, rendant l'achat impossible
|
||||
* en pratique. La colonne à gauche laisse assez de marge verticale ET
|
||||
* horizontale (largeur de carte ~132px) pour que la carte agrandie
|
||||
* s'affiche toujours entièrement à l'intérieur du canvas.
|
||||
*/
|
||||
_itemTokensX() {
|
||||
return 70
|
||||
}
|
||||
|
||||
_itemTokensStartY() {
|
||||
return 130
|
||||
}
|
||||
|
||||
_itemTokensSpacing() {
|
||||
return ICON_SIZE + 60 // marge verticale généreuse pour la carte agrandie (H ~172px)
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)construit la colonne à gauche avec UNIQUEMENT les objets encore
|
||||
* disponibles à l'achat. Une fois un objet acheté (par n'importe quel
|
||||
* joueur), il disparaît complètement de cette colonne — inutile de
|
||||
* l'afficher grisé/"INDISPONIBLE" puisqu'il ne peut plus jamais être
|
||||
* acheté par personne (stock unique pour toute la partie). Les objets
|
||||
* restants remontent pour combler l'espace laissé vide.
|
||||
*/
|
||||
_buildItemTokens() {
|
||||
this.itemTokens.forEach(t => t.destroy())
|
||||
this.itemTokens = []
|
||||
|
||||
const x = this._itemTokensX()
|
||||
const startY = this._itemTokensStartY()
|
||||
const spacing = this._itemTokensSpacing()
|
||||
|
||||
const state = this._shopState ?? { myItems: [], availableItems: ITEMS.map(i => i.id) }
|
||||
|
||||
// Ne garder que les objets réellement encore en stock. "owned" (déjà
|
||||
// possédé par MOI) est logiquement un sous-cas exclu de availableItems
|
||||
// côté serveur, mais on l'exclut aussi explicitement par sécurité.
|
||||
const stillAvailable = ITEMS.filter(itemData =>
|
||||
state.availableItems.includes(itemData.id) && !state.myItems.includes(itemData.id)
|
||||
)
|
||||
|
||||
// Libellé au-dessus de la colonne : sans lui, rien n'indique que ces
|
||||
// petites icônes sont cliquables pour acheter (elles n'ont ni bouton ni
|
||||
// panneau visible en permanence, seul un survol révèle la carte détaillée).
|
||||
// Masqué si plus aucun objet n'est disponible (colonne vide).
|
||||
if (stillAvailable.length > 0) {
|
||||
if (!this.itemTokensLabel) {
|
||||
this.itemTokensLabel = this.add.text(x, startY - ICON_SIZE / 2 - 16, 'OBJETS\n(survoler)', {
|
||||
fontSize: '9px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: '#6a5040', align: 'center'
|
||||
}).setOrigin(0.5, 1)
|
||||
} else {
|
||||
this.itemTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
|
||||
this.itemTokensLabel.setVisible(true)
|
||||
}
|
||||
} else if (this.itemTokensLabel) {
|
||||
this.itemTokensLabel.setVisible(false)
|
||||
}
|
||||
|
||||
// Jauges du joueur local, pour déterminer si le bouton ACHETER de chaque
|
||||
// carte doit être cliquable (jauge suffisante) ou grisé.
|
||||
const me = this.store.players.find(p => p.teamIndex === this.store.myTeamIndex)
|
||||
const myGauges = me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 }
|
||||
|
||||
stillAvailable.forEach((itemData, i) => {
|
||||
const affordable = (myGauges[itemData.attr] ?? 0) >= itemData.cost
|
||||
|
||||
const token = new ItemBoardToken(this, x, startY + i * spacing, itemData, 'available', affordable)
|
||||
token.on('buy', (data) => this._onBuyItem(data))
|
||||
this.itemTokens.push(token)
|
||||
})
|
||||
}
|
||||
|
||||
/** Recale la colonne de jetons verticalement sur le bord gauche du canvas. */
|
||||
_layoutItemTokens() {
|
||||
const x = this._itemTokensX()
|
||||
const startY = this._itemTokensStartY()
|
||||
const spacing = this._itemTokensSpacing()
|
||||
if (this.itemTokensLabel) this.itemTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
|
||||
this.itemTokens.forEach((token, i) => token.setPosition(x, startY + i * spacing))
|
||||
}
|
||||
|
||||
_onBuyItem(itemData) {
|
||||
this.socket.emit('buy-item', { roomId: this.roomId, itemId: itemData.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)construit les mini-cartes des objets que JE possède, affichées juste
|
||||
* à droite de ma main — comme si je les avais posées devant moi sur la
|
||||
* table, à portée de main avec mes cartes personnage.
|
||||
*/
|
||||
_buildMyItemTokens() {
|
||||
this.myItemTokens.forEach(t => t.destroy())
|
||||
this.myItemTokens = []
|
||||
|
||||
const myItemIds = this._shopState?.myItems ?? []
|
||||
if (myItemIds.length === 0) return
|
||||
|
||||
myItemIds.forEach((itemId, i) => {
|
||||
const itemData = ITEMS.find(it => it.id === itemId)
|
||||
if (!itemData) return
|
||||
// Toujours 'owned' ici : ce sont mes objets déjà achetés, non cliquables.
|
||||
// displayScale=0.7 réduit uniquement le jeton compact ; la carte
|
||||
// détaillée au survol reste à sa taille normale (voir ItemBoardToken).
|
||||
const token = new ItemBoardToken(this, 0, 0, itemData, 'owned', false, 0.7)
|
||||
this.myItemTokens.push(token)
|
||||
})
|
||||
|
||||
this._layoutMyItemTokens()
|
||||
}
|
||||
|
||||
/** Positionne mes mini-cartes objets juste à droite de ma main de cartes. */
|
||||
_layoutMyItemTokens() {
|
||||
if (this.myItemTokens.length === 0) return
|
||||
|
||||
const { handY, spacing, startX } = this._handGeometry(this.handCards.length)
|
||||
const handRightEdge = startX + Math.max(this.handCards.length - 1, 0) * spacing + CARD_W / 2
|
||||
const gap = 18
|
||||
const tokenGap = ICON_SIZE * 0.7 + 10
|
||||
|
||||
this.myItemTokens.forEach((token, i) => {
|
||||
token.setPosition(handRightEdge + gap + ICON_SIZE * 0.7 / 2 + i * tokenGap, handY)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Main du joueur ──────────────────────────────────────────────────────────
|
||||
|
||||
_buildHand(cards) {
|
||||
/**
|
||||
* Calcule la géométrie de la main (position Y, espacement, X de départ)
|
||||
* pour un nombre de cartes donné. Centralisé ici pour éviter la triplication
|
||||
* de cette formule (_buildHand / _animateDraw / _repositionHand la
|
||||
* calculaient chacune séparément), et réutilisé pour positionner mes
|
||||
* objets achetés juste à côté de la main.
|
||||
*/
|
||||
_handGeometry(cardCount) {
|
||||
const { width, height } = this.scale
|
||||
const n = Math.max(cardCount, 1)
|
||||
const handY = height - BOTTOM_MARGIN - HAND_Y_OFFSET
|
||||
const spacing = Math.min(HAND_SPACING_MAX, (width * 0.85) / n)
|
||||
const totalW = spacing * (n - 1)
|
||||
const startX = width / 2 - totalW / 2
|
||||
return { handY, spacing, startX }
|
||||
}
|
||||
|
||||
_buildHand(cards) {
|
||||
// Vider l'ancienne main
|
||||
this.handCards.forEach(c => c.destroy())
|
||||
this.handCards = []
|
||||
|
||||
if (!cards || cards.length === 0) return
|
||||
|
||||
const handY = height - BOTTOM_MARGIN - 110
|
||||
const spacing = Math.min(150, (width * 0.85) / cards.length)
|
||||
const totalW = spacing * (cards.length - 1)
|
||||
const startX = width / 2 - totalW / 2
|
||||
const { handY, spacing, startX } = this._handGeometry(cards.length)
|
||||
|
||||
cards.forEach((cardData, i) => {
|
||||
const card = new CardObject(this, startX + i * spacing, handY, cardData)
|
||||
card.on('select', this._onCardSelected, this)
|
||||
this.handCards.push(card)
|
||||
})
|
||||
|
||||
this._layoutMyItemTokens()
|
||||
}
|
||||
|
||||
_onCardSelected(card) {
|
||||
@@ -261,14 +570,23 @@ export class GameScene extends Phaser.Scene {
|
||||
* @param {Array} gains - gains[i] = { food, war, health, diplo } pour le joueur i
|
||||
*/
|
||||
_showPlayedCards(playedCards, gains = []) {
|
||||
// Mémoriser le dernier résultat affiché pour pouvoir le redessiner si les
|
||||
// slots sont reconstruits pendant la phase 'resolve' (ex: resize du canvas).
|
||||
this._lastRoundResult = { playedCards, gains }
|
||||
|
||||
this.resolveObjects.forEach(o => o.destroy())
|
||||
this.resolveObjects = []
|
||||
|
||||
// Style épuré, cohérent avec CardObject : portrait plein cadre, fin
|
||||
// liseré couleur équipe, nom simple en bas, valeurs discrètes aux coins.
|
||||
const rCARD_W = CARD_W
|
||||
const rCARD_H = CARD_H
|
||||
|
||||
const ATTRS_POS = [
|
||||
{ key: 'food', label: 'NOU', dx: -28, dy: -20 },
|
||||
{ key: 'war', label: 'GUE', dx: 28, dy: -20 },
|
||||
{ key: 'health', label: 'SAN', dx: -28, dy: 30 },
|
||||
{ key: 'diplo', label: 'DIP', dx: 28, dy: 30 }
|
||||
{ key: 'food', dx: -rCARD_W / 2 + 12, dy: -rCARD_H / 2 + 12 },
|
||||
{ key: 'war', dx: rCARD_W / 2 - 12, dy: -rCARD_H / 2 + 12 },
|
||||
{ key: 'health', dx: -rCARD_W / 2 + 12, dy: rCARD_H / 2 - 34 },
|
||||
{ key: 'diplo', dx: rCARD_W / 2 - 12, dy: rCARD_H / 2 - 34 }
|
||||
]
|
||||
|
||||
playedCards.forEach((entry, i) => {
|
||||
@@ -277,67 +595,64 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
const teamColor = TEAM_COLORS[entry.teamIndex] ?? 0x888888
|
||||
const gain = gains[i] ?? {}
|
||||
const textureKey = `card-${entry.cardId}`
|
||||
const hasTexture = entry.cardId && this.textures.exists(textureKey)
|
||||
|
||||
// ── Fond carte ──────────────────────────────────────────
|
||||
const g = this.add.graphics()
|
||||
g.fillStyle(this._darken(teamColor, 0.25))
|
||||
g.fillRoundedRect(-55, -90, 110, 180, 6)
|
||||
g.lineStyle(2, teamColor)
|
||||
g.strokeRoundedRect(-55, -90, 110, 180, 6)
|
||||
g.x = slot.x
|
||||
g.y = slot.y
|
||||
this.resolveObjects.push(g)
|
||||
// ── Portrait ou fond de secours ────────────────────────────
|
||||
if (hasTexture) {
|
||||
const img = this.add.image(slot.x, slot.y, textureKey)
|
||||
this.resolveObjects.push(img)
|
||||
} else {
|
||||
const bg = this.add.graphics()
|
||||
bg.fillStyle(this._darken(teamColor, 0.3))
|
||||
bg.fillRoundedRect(-rCARD_W / 2, -rCARD_H / 2, rCARD_W, rCARD_H, 10)
|
||||
bg.x = slot.x
|
||||
bg.y = slot.y
|
||||
this.resolveObjects.push(bg)
|
||||
}
|
||||
|
||||
// ── Nom du joueur ────────────────────────────────────────
|
||||
// ── Fin liseré couleur équipe ───────────────────────────────
|
||||
const border = this.add.graphics()
|
||||
border.lineStyle(1.5, teamColor, 0.9)
|
||||
border.strokeRoundedRect(-rCARD_W / 2, -rCARD_H / 2, rCARD_W, rCARD_H, 10)
|
||||
border.x = slot.x
|
||||
border.y = slot.y
|
||||
this.resolveObjects.push(border)
|
||||
|
||||
// ── Nom du joueur (au-dessus de la carte) ──────────────────
|
||||
this.resolveObjects.push(
|
||||
this.add.text(slot.x, slot.y - 78, entry.playerName, {
|
||||
fontSize: '10px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
|
||||
this.add.text(slot.x, slot.y - rCARD_H / 2 - 14, entry.playerName, {
|
||||
fontSize: '11px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
|
||||
}).setOrigin(0.5)
|
||||
)
|
||||
|
||||
// ── Nom de la carte ──────────────────────────────────────
|
||||
// ── Nom de la carte, simple texte en bas ────────────────────
|
||||
this.resolveObjects.push(
|
||||
this.add.text(slot.x, slot.y - 62, entry.cardName, {
|
||||
fontSize: '12px', fontFamily: 'Georgia', color: '#d4a732', fontStyle: 'bold'
|
||||
this.add.text(slot.x, slot.y + rCARD_H / 2 - 14, entry.cardName, {
|
||||
fontSize: '13px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: '#f5e6c8', stroke: '#000000', strokeThickness: 3
|
||||
}).setOrigin(0.5)
|
||||
)
|
||||
|
||||
// ── 4 badges attribut + gain ─────────────────────────────
|
||||
ATTRS_POS.forEach(({ key, label, dx, dy }) => {
|
||||
// ── Valeurs discrètes aux 4 coins + gain au-dessus si positif ────────
|
||||
ATTRS_POS.forEach(({ key, dx, dy }) => {
|
||||
const cx = slot.x + dx
|
||||
const cy = slot.y + dy
|
||||
const cardVal = entry[key] ?? 0
|
||||
const gainVal = gain[key] ?? 0
|
||||
|
||||
// Badge fond coloré
|
||||
const badge = this.add.graphics()
|
||||
badge.fillStyle(ATTR_COLORS[key], gainVal > 0 ? 1.0 : 0.45)
|
||||
badge.fillRoundedRect(-22, -20, 44, 40, 4)
|
||||
badge.x = cx
|
||||
badge.y = cy
|
||||
this.resolveObjects.push(badge)
|
||||
|
||||
// Valeur de la carte
|
||||
this.resolveObjects.push(
|
||||
this.add.text(cx, cy - 7, String(cardVal), {
|
||||
fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: '#ffffff', stroke: '#000000', strokeThickness: 2
|
||||
this.add.text(cx, cy, String(cardVal), {
|
||||
fontSize: '15px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: ATTR_COLORS_CSS[key], stroke: '#000000', strokeThickness: 3.5
|
||||
}).setOrigin(0.5)
|
||||
)
|
||||
|
||||
// Label abrégé
|
||||
this.resolveObjects.push(
|
||||
this.add.text(cx, cy + 5, label, {
|
||||
fontSize: '7px', fontFamily: 'Arial', color: '#ffffffcc'
|
||||
}).setOrigin(0.5)
|
||||
)
|
||||
|
||||
// Gain (affiché en vert si > 0)
|
||||
if (gainVal > 0) {
|
||||
this.resolveObjects.push(
|
||||
this.add.text(cx, cy + 16, `+${gainVal}`, {
|
||||
fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: '#ffffff', stroke: '#000000', strokeThickness: 2
|
||||
this.add.text(cx, cy - 16, `+${gainVal}`, {
|
||||
fontSize: '11px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: '#7CFC9A', stroke: '#000000', strokeThickness: 3
|
||||
}).setOrigin(0.5)
|
||||
)
|
||||
}
|
||||
@@ -352,22 +667,69 @@ export class GameScene extends Phaser.Scene {
|
||||
this.store.updateState(state)
|
||||
this.turnText.setText(`Tour ${state.currentTurn} / ${state.maxTurns}`)
|
||||
|
||||
// Mémoriser l'état des objets (possédés/disponibles, priorité du tour)
|
||||
// et ne rafraîchir les jetons du plateau que si leur statut, OU les
|
||||
// jauges du joueur local (qui déterminent si le bouton ACHETER est
|
||||
// cliquable), ont réellement changé. Sans cette garde, la reconstruction
|
||||
// se déclencherait à chaque 'game-state' reçu (rediffusé à tous à chaque
|
||||
// carte jouée par n'importe qui), ce qui interromprait l'agrandissement
|
||||
// au survol en plein milieu.
|
||||
const newShopState = {
|
||||
myItems: state.myItems ?? [],
|
||||
pendingBuyItemId: state.pendingBuyItemId ?? null,
|
||||
availableItems: state.availableItems ?? [],
|
||||
firstPlayerName: state.firstPlayerName ?? null
|
||||
}
|
||||
const me = (state.players ?? []).find(p => p.teamIndex === state.myTeamIndex)
|
||||
const myGaugesKey = JSON.stringify(me?.gauges ?? {})
|
||||
const shopChanged = !this._shopState ||
|
||||
JSON.stringify(this._shopState.myItems) !== JSON.stringify(newShopState.myItems) ||
|
||||
JSON.stringify(this._shopState.availableItems) !== JSON.stringify(newShopState.availableItems) ||
|
||||
this._lastMyGaugesKey !== myGaugesKey
|
||||
this._shopState = newShopState
|
||||
this._lastMyGaugesKey = myGaugesKey
|
||||
if (shopChanged) this._buildItemTokens()
|
||||
|
||||
// Reconstruire mes mini-cartes objets uniquement si MA liste d'objets
|
||||
// possédés a changé (indépendant de shopChanged qui réagit aussi aux
|
||||
// objets encore disponibles / jauges, non pertinents ici).
|
||||
const myItemsKey = JSON.stringify(newShopState.myItems)
|
||||
if (this._lastMyItemsKey !== myItemsKey) {
|
||||
this._lastMyItemsKey = myItemsKey
|
||||
this._buildMyItemTokens()
|
||||
}
|
||||
|
||||
if (state.phase === 'selecting') {
|
||||
this._canPlay = true
|
||||
this.resolveObjects.forEach(o => o.destroy())
|
||||
this.resolveObjects = []
|
||||
this._lastRoundResult = null
|
||||
this.phaseText.setText('Choisissez une carte puis confirmez')
|
||||
this.readyText.setText('')
|
||||
this._deactivateBtn()
|
||||
// (Re)construire les slots si le nombre de joueurs a changé ou premier tour
|
||||
if (this.playSlots.length !== state.players.length) {
|
||||
// (Re)construire les slots si le nombre de joueurs a changé (premier
|
||||
// tour), OU si la liste d'objets possédés par un adversaire a changé
|
||||
// (leurs mini-cartes objets, affichées sous leur slot, doivent alors
|
||||
// être mises à jour).
|
||||
const opponentItemsKey = JSON.stringify(
|
||||
state.players.map(p => p.items ?? [])
|
||||
)
|
||||
if (this.playSlots.length !== state.players.length || this._lastOpponentItemsKey !== opponentItemsKey) {
|
||||
this._lastOpponentItemsKey = opponentItemsKey
|
||||
this._buildSlots(state.players.length)
|
||||
}
|
||||
// Si ce n'est pas le premier tour, animer la pioche avant d'afficher la main
|
||||
if (state.currentTurn > 1) {
|
||||
this._animateDraw(state.hand)
|
||||
} else {
|
||||
this._buildHand(state.hand)
|
||||
// Ne (re)construire/animer la main que lorsque le tour a réellement changé.
|
||||
// Le serveur rediffuse 'game-state' à tous les joueurs à chaque carte jouée
|
||||
// par n'importe qui ; sans cette garde, la main serait reconstruite/animée
|
||||
// plusieurs fois pour le même tour, provoquant un doublon visuel de carte.
|
||||
if (this._lastHandTurn !== state.currentTurn) {
|
||||
this._lastHandTurn = state.currentTurn
|
||||
// Si ce n'est pas le premier tour, animer la pioche avant d'afficher la main
|
||||
if (state.currentTurn > 1) {
|
||||
this._animateDraw(state.hand)
|
||||
} else {
|
||||
this._buildHand(state.hand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,11 +751,43 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('round-result', ({ playedCards, gains }) => {
|
||||
this.socket.on('round-result', ({ playedCards, gains, boughtItems }) => {
|
||||
this.phaseText.setText('Résolution du tour...')
|
||||
this.readyText.setText('')
|
||||
this._showPlayedCards(playedCards, gains)
|
||||
|
||||
if (boughtItems && boughtItems.length > 0) {
|
||||
const names = boughtItems.map(b => `${b.playerName} → ${b.itemName}`).join(' | ')
|
||||
this.readyText.setText(`🛒 ${names}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Retour visuel des refus d'achat (ex: jauge insuffisante, objet déjà
|
||||
// pris) : sans cet écouteur, le serveur répond bien via socket.emit('error',
|
||||
// ...) mais rien côté client n'affichait jamais ce message — un clic sur
|
||||
// une carte objet semblait alors ne rien faire, laissant croire qu'il
|
||||
// n'y avait aucun moyen d'acheter.
|
||||
this.socket.on('error', ({ message }) => {
|
||||
if (!message) return
|
||||
this._showErrorToast(message)
|
||||
})
|
||||
}
|
||||
|
||||
/** Affiche un message d'erreur temporaire (ex: achat refusé) près du bouton de confirmation. */
|
||||
_showErrorToast(message) {
|
||||
if (this._errorText) { this._errorText.destroy(); this._errorText = null }
|
||||
if (this._errorTimer) { clearTimeout(this._errorTimer); this._errorTimer = null }
|
||||
|
||||
const { width } = this.scale
|
||||
this._errorText = this.add.text(width / 2, this._playButtonY() - 46, `⚠ ${message}`, {
|
||||
fontSize: '13px', fontFamily: 'Georgia', fontStyle: 'bold',
|
||||
color: '#ff6b6b', stroke: '#000000', strokeThickness: 3
|
||||
}).setOrigin(0.5).setDepth(150)
|
||||
|
||||
this._errorTimer = setTimeout(() => {
|
||||
if (this._errorText) { this._errorText.destroy(); this._errorText = null }
|
||||
this._errorTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// ─── Animation pioche ────────────────────────────────────────────────────────
|
||||
@@ -403,8 +797,6 @@ export class GameScene extends Phaser.Scene {
|
||||
* (toujours la dernière du tableau, ajoutée par nextTurn côté serveur)
|
||||
*/
|
||||
_animateDraw(cards) {
|
||||
const { width, height } = this.scale
|
||||
|
||||
// Afficher immédiatement les cartes déjà en main (toutes sauf la dernière)
|
||||
const existing = cards.slice(0, -1)
|
||||
const newCard = cards[cards.length - 1]
|
||||
@@ -414,11 +806,8 @@ export class GameScene extends Phaser.Scene {
|
||||
if (!newCard) return
|
||||
|
||||
// Calculer la position finale de la nouvelle carte dans la main complète
|
||||
const handY = height - BOTTOM_MARGIN - 110
|
||||
const spacing = Math.min(150, (width * 0.85) / cards.length)
|
||||
const totalW = spacing * (cards.length - 1)
|
||||
const startX = width / 2 - totalW / 2
|
||||
const finalX = startX + (cards.length - 1) * spacing
|
||||
const { handY, spacing, startX } = this._handGeometry(cards.length)
|
||||
const finalX = startX + (cards.length - 1) * spacing
|
||||
|
||||
// Créer la carte hors écran (au-dessus)
|
||||
const card = new CardObject(this, finalX, -200, newCard)
|
||||
@@ -446,12 +835,10 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
/** Repositionne toutes les cartes en main avec le bon espacement */
|
||||
_repositionHand() {
|
||||
const { width, height } = this.scale
|
||||
const handY = height - BOTTOM_MARGIN - 110
|
||||
const n = this.handCards.length
|
||||
const spacing = Math.min(150, (width * 0.85) / n)
|
||||
const totalW = spacing * (n - 1)
|
||||
const startX = width / 2 - totalW / 2
|
||||
const n = this.handCards.length
|
||||
if (n === 0) return
|
||||
|
||||
const { handY, spacing, startX } = this._handGeometry(n)
|
||||
|
||||
this.handCards.forEach((card, i) => {
|
||||
const targetX = startX + i * spacing
|
||||
@@ -464,6 +851,8 @@ export class GameScene extends Phaser.Scene {
|
||||
ease: 'Power2'
|
||||
})
|
||||
})
|
||||
|
||||
this._layoutMyItemTokens()
|
||||
}
|
||||
|
||||
// ─── Utilitaires ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user