feat: bâtiments achetables (Église, Château Royal) avec synergie objets

- Nouveau server/src/game/buildings.js : 2 bâtiments, chacun couvrant 2
  attributs, coût réparti sur les deux jauges, garantit +1 par tour sur
  chaque attribut, avec bonus de synergie +1 si l'objet correspondant est
  aussi possédé (Église+Trousse/Sceau, Château+Charrue/Trébuchet).
- Room.js : buyBuilding() (validation coût multi-jauges), résolution des
  achats en attente avec la même priorité/stock unique que les objets,
  _applyBuildingEffects() (garantie de base + synergie, jamais de recul par
  rapport aux gains déjà boostés par un objet). getStateFor/getPlayersInfo
  exposent buildings/myBuildings/availableBuildings/pendingBuyBuildingId.
- Nouvel event socket 'buy-building', boughtBuildings inclus dans round-result.
- Client : buildings.js (miroir), BootScene génère les textures portrait des
  bâtiments (méthode _generatePortraitTexture généralisée, réutilisée avec
  les objets). ItemBoardToken généralisé (kind='item'|'building', hAnchor
  'left'|'right') pour afficher indifféremment un objet ou un bâtiment, avec
  coût détaillé par jauge affiché sur la carte agrandie pour les bâtiments.
- GameScene : boutique de bâtiments en colonne à DROITE du plateau
  (symétrique à la boutique d'objets à gauche), mes bâtiments possédés
  affichés à GAUCHE de ma main (symétrique à mes objets à droite), bâtiments
  des adversaires affichés sous leurs objets au niveau de leur slot.
- Fix : la carte détaillée héritait par défaut d'un ancrage "s'étend vers la
  droite" adapté à la boutique d'objets (bord gauche du canvas) mais cassait
  la boutique de bâtiments (bord droit) — le bouton ACHETER se retrouvait
  hors du canvas et donc injoignable au clic. Ajout du paramètre hAnchor.
- 26 nouveaux tests unitaires serveur (buildings.js + Room.js : validations
  d'achat multi-jauges, priorité/stock unique, effets de base et synergie).
- 6 nouveaux tests E2E Playwright (boutique bâtiments : affichage, position,
  carte détaillée dans les limites du canvas, coût affiché, achat complet).
- Fix flakiness des tests E2E d'achat (item-shop + building-shop) : le mode
  'min' de playCard garantit désormais que l'adversaire joue sa carte la
  plus faible sur l'attribut ciblé, au lieu de simplement jouer un autre
  attribut (insuffisant, le gain dépend de la comparaison au voisin gauche).
This commit is contained in:
Kevin L'Hereec committed 2026-08-21 16:19:18 +02:00
1 parent 11cba0467b
commit 9139989fa7
14 files changed
+1222 -89

No files matched your search

+18 -9
View File
@@ -2,6 +2,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'
import { BUILDINGS } from '../buildings'
const CARD_IMAGES = [
{ id: 'queen', file: 'reine.png' },
@@ -52,6 +53,11 @@ export class BootScene extends Phaser.Scene {
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}`)
})
}
create() {
@@ -63,7 +69,12 @@ export class BootScene extends Phaser.Scene {
// 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._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}`)
})
this.scene.start('GameScene')
@@ -133,15 +144,13 @@ export class BootScene extends Phaser.Scene {
}
/**
* 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).
* 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.
*/
_generateItemTexture(id) {
const rawKey = `raw-item-${id}`
const outKey = `item-${id}`
_generatePortraitTexture(rawKey, outKey) {
if (!this.textures.exists(rawKey)) return
if (this.textures.exists(outKey)) return
+174 -19
View File
@@ -3,6 +3,7 @@ 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'
import { BUILDINGS, getBuildingById } from '../buildings'
// Marge depuis le bas du canvas pour éviter la barre Windows
const BOTTOM_MARGIN = 60
@@ -50,10 +51,15 @@ export class GameScene extends Phaser.Scene {
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.itemTokens = [] // ItemBoardToken (boutique objets, à gauche) 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
// ── Bâtiments sur le plateau (même principe, boutique à DROITE) ────────────
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
}
create() {
@@ -64,6 +70,7 @@ export class GameScene extends Phaser.Scene {
this._buildTable()
this._buildPlayButton()
this._buildItemTokens()
this._buildBuildingTokens()
this._setupSocketListeners()
// Le canvas Phaser (mode Scale.RESIZE) se redimensionne quand le HUD Vue
@@ -186,8 +193,9 @@ export class GameScene extends Phaser.Scene {
// Bouton de confirmation + compteur
this._layoutPlayButton()
// Bande de jetons d'objet en haut du plateau
// Colonnes de jetons objets (gauche) et bâtiments (droite)
this._layoutItemTokens()
this._layoutBuildingTokens()
// Main du joueur (repositionnement simple, sans reconstruction)
this._repositionHand()
@@ -196,12 +204,14 @@ export class GameScene extends Phaser.Scene {
_buildSlots(playerCount) {
const { width, height } = this.scale
// Détruire les anciens slots (et les mini-cartes objets des adversaires)
// Détruire les anciens slots (et les mini-cartes objets/bâtiments des adversaires)
this.slotObjects.forEach(o => o.destroy())
this.slotObjects = []
this.playSlots = []
this.opponentItemTokens.forEach(t => t.destroy())
this.opponentItemTokens = []
this.opponentBuildingTokens.forEach(t => t.destroy())
this.opponentBuildingTokens = []
const slotY = height / 2 - 60
const spacing = CARD_W + 30
@@ -227,10 +237,11 @@ export class GameScene extends Phaser.Scene {
}).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).
// Mini-cartes objets/bâtiments "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/bâtiments 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 ?? []
@@ -244,6 +255,16 @@ export class GameScene extends Phaser.Scene {
const token = new ItemBoardToken(this, startTokenX + k * tokenGap, slotY + 128, item, 'owned', false, 0.55)
this.opponentItemTokens.push(token)
})
// Bâtiments possédés, sur une seconde rangée sous les objets
const ownedBuildings = player?.buildings ?? []
const startBuildingX = x - (ownedBuildings.length - 1) * tokenGap / 2
ownedBuildings.forEach((buildingId, k) => {
const building = getBuildingById(buildingId)
if (!building) return
const token = new ItemBoardToken(this, startBuildingX + k * tokenGap, slotY + 152, building, 'owned', false, 0.55, 'building')
this.opponentBuildingTokens.push(token)
})
}
this.playSlots.push({ x, y: slotY })
@@ -496,6 +517,122 @@ export class GameScene extends Phaser.Scene {
})
}
// ─── Bâtiments sur le plateau (miroir des objets, boutique à DROITE) ───────
_buildingTokensX() {
return this.scale.width - 70
}
_buildingTokensStartY() {
return 130
}
_buildingTokensSpacing() {
return ICON_SIZE + 60
}
/**
* (Re)construit la colonne à DROITE avec uniquement les bâtiments encore
* disponibles à l'achat — même logique que _buildItemTokens (disparition
* complète une fois acheté, les restants remontent pour combler le vide).
*/
_buildBuildingTokens() {
this.buildingTokens.forEach(t => t.destroy())
this.buildingTokens = []
const x = this._buildingTokensX()
const startY = this._buildingTokensStartY()
const spacing = this._buildingTokensSpacing()
const state = this._shopState ?? { myBuildings: [], availableBuildings: BUILDINGS.map(b => b.id) }
const stillAvailable = BUILDINGS.filter(b =>
state.availableBuildings.includes(b.id) && !state.myBuildings.includes(b.id)
)
if (stillAvailable.length > 0) {
if (!this.buildingTokensLabel) {
this.buildingTokensLabel = this.add.text(x, startY - ICON_SIZE / 2 - 16, 'BÂTIMENTS\n(survoler)', {
fontSize: '9px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#6a5040', align: 'center'
}).setOrigin(0.5, 1)
} else {
this.buildingTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
this.buildingTokensLabel.setVisible(true)
}
} else if (this.buildingTokensLabel) {
this.buildingTokensLabel.setVisible(false)
}
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((building, i) => {
const affordable = Object.entries(building.cost)
.every(([attr, cost]) => (myGauges[attr] ?? 0) >= cost)
// hAnchor='right' : cette boutique est sur le bord DROIT du canvas,
// la carte détaillée doit donc s'étendre vers la GAUCHE au survol
// pour ne jamais déborder hors du canvas (sinon son bouton ACHETER
// devient injoignable au clic, cf. bug corrigé).
const token = new ItemBoardToken(this, x, startY + i * spacing, building, 'available', affordable, 1, 'building', 'right')
token.on('buy', (data) => this._onBuyBuilding(data))
this.buildingTokens.push(token)
})
}
/** Recale la colonne de jetons bâtiments verticalement sur le bord droit du canvas. */
_layoutBuildingTokens() {
const x = this._buildingTokensX()
const startY = this._buildingTokensStartY()
const spacing = this._buildingTokensSpacing()
if (this.buildingTokensLabel) this.buildingTokensLabel.setPosition(x, startY - ICON_SIZE / 2 - 16)
this.buildingTokens.forEach((token, i) => token.setPosition(x, startY + i * spacing))
}
_onBuyBuilding(buildingData) {
this.socket.emit('buy-building', { roomId: this.roomId, buildingId: buildingData.id })
}
/**
* (Re)construit les mini-cartes des bâtiments que JE possède, affichées
* juste à GAUCHE de ma main (symétrique de mes objets à droite) — posés
* devant moi sur la table, de l'autre côté de mes cartes personnage.
*/
_buildMyBuildingTokens() {
this.myBuildingTokens.forEach(t => t.destroy())
this.myBuildingTokens = []
const myBuildingIds = this._shopState?.myBuildings ?? []
if (myBuildingIds.length === 0) return
myBuildingIds.forEach((buildingId) => {
const buildingData = BUILDINGS.find(b => b.id === buildingId)
if (!buildingData) return
const token = new ItemBoardToken(this, 0, 0, buildingData, 'owned', false, 0.7, 'building')
this.myBuildingTokens.push(token)
})
this._layoutMyBuildingTokens()
}
/** Positionne mes mini-cartes bâtiments juste à gauche de ma main de cartes. */
_layoutMyBuildingTokens() {
if (this.myBuildingTokens.length === 0) return
const { handY, spacing, startX } = this._handGeometry(this.handCards.length)
const handLeftEdge = startX - CARD_W / 2
const gap = 18
const tokenGap = ICON_SIZE * 0.7 + 10
// Placés en partant du bord gauche de la main vers la gauche (ordre
// inversé pour que le premier bâtiment acheté reste le plus proche
// de la main, symétrique à mes objets qui partent du bord droit).
this.myBuildingTokens.forEach((token, i) => {
token.setPosition(handLeftEdge - gap - ICON_SIZE * 0.7 / 2 - i * tokenGap, handY)
})
}
// ─── Main du joueur ──────────────────────────────────────────────────────────
/**
@@ -531,6 +668,7 @@ export class GameScene extends Phaser.Scene {
})
this._layoutMyItemTokens()
this._layoutMyBuildingTokens()
}
_onCardSelected(card) {
@@ -676,8 +814,11 @@ export class GameScene extends Phaser.Scene {
// au survol en plein milieu.
const newShopState = {
myItems: state.myItems ?? [],
pendingBuyItemId: state.pendingBuyItemId ?? null,
availableItems: state.availableItems ?? [],
myBuildings: state.myBuildings ?? [],
pendingBuyItemId: state.pendingBuyItemId ?? null,
pendingBuyBuildingId: state.pendingBuyBuildingId ?? null,
availableItems: state.availableItems ?? [],
availableBuildings: state.availableBuildings ?? [],
firstPlayerName: state.firstPlayerName ?? null
}
const me = (state.players ?? []).find(p => p.teamIndex === state.myTeamIndex)
@@ -686,18 +827,28 @@ export class GameScene extends Phaser.Scene {
JSON.stringify(this._shopState.myItems) !== JSON.stringify(newShopState.myItems) ||
JSON.stringify(this._shopState.availableItems) !== JSON.stringify(newShopState.availableItems) ||
this._lastMyGaugesKey !== myGaugesKey
const buildingShopChanged = !this._shopState ||
JSON.stringify(this._shopState.myBuildings) !== JSON.stringify(newShopState.myBuildings) ||
JSON.stringify(this._shopState.availableBuildings) !== JSON.stringify(newShopState.availableBuildings) ||
this._lastMyGaugesKey !== myGaugesKey
this._shopState = newShopState
this._lastMyGaugesKey = myGaugesKey
if (shopChanged) this._buildItemTokens()
if (buildingShopChanged) this._buildBuildingTokens()
// 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
// Reconstruire mes mini-cartes objets/bâtiments uniquement si MA liste
// possédée 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()
}
const myBuildingsKey = JSON.stringify(newShopState.myBuildings)
if (this._lastMyBuildingsKey !== myBuildingsKey) {
this._lastMyBuildingsKey = myBuildingsKey
this._buildMyBuildingTokens()
}
if (state.phase === 'selecting') {
this._canPlay = true
@@ -708,11 +859,11 @@ export class GameScene extends Phaser.Scene {
this.readyText.setText('')
this._deactivateBtn()
// (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).
// tour), OU si la liste d'objets/bâtiments possédés par un adversaire
// a changé (leurs mini-cartes, affichées sous leur slot, doivent
// alors être mises à jour).
const opponentItemsKey = JSON.stringify(
state.players.map(p => p.items ?? [])
state.players.map(p => [p.items ?? [], p.buildings ?? []])
)
if (this.playSlots.length !== state.players.length || this._lastOpponentItemsKey !== opponentItemsKey) {
this._lastOpponentItemsKey = opponentItemsKey
@@ -751,14 +902,17 @@ export class GameScene extends Phaser.Scene {
}
})
this.socket.on('round-result', ({ playedCards, gains, boughtItems }) => {
this.socket.on('round-result', ({ playedCards, gains, boughtItems, boughtBuildings }) => {
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}`)
const boughtLabels = [
...(boughtItems ?? []).map(b => `${b.playerName} → ${b.itemName}`),
...(boughtBuildings ?? []).map(b => `${b.playerName} → ${b.buildingName}`)
]
if (boughtLabels.length > 0) {
this.readyText.setText(`🛒 ${boughtLabels.join(' | ')}`)
}
})
@@ -853,6 +1007,7 @@ export class GameScene extends Phaser.Scene {
})
this._layoutMyItemTokens()
this._layoutMyBuildingTokens()
}
// ─── Utilitaires ─────────────────────────────────────────────────────────────