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:
1 parent
11cba0467b
commit
9139989fa7
14 files changed
+1222
-89
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Regnum - Définition des bâtiments achetables (miroir du serveur, pour
|
||||
* l'affichage) — voir server/src/game/buildings.js pour la logique.
|
||||
*
|
||||
* Contrairement aux objets, un bâtiment coûte des points sur DEUX jauges à
|
||||
* la fois (cost = { attr1, attr2 }) et garantit un minimum sur chacune,
|
||||
* avec un bonus de synergie si l'objet correspondant est aussi possédé.
|
||||
*/
|
||||
|
||||
export const BUILDINGS = [
|
||||
{
|
||||
id: 'church',
|
||||
name: 'Église',
|
||||
attrs: ['health', 'diplo'],
|
||||
cost: { health: 3, diplo: 2 },
|
||||
image: 'eglise.png',
|
||||
description: 'Garantit +1 en Santé et +1 en Diplomatie à chaque tour. +1 supplémentaire sur chaque attribut si l\'objet correspondant (Trousse du Médecin / Sceau Royal) est aussi possédé.'
|
||||
},
|
||||
{
|
||||
id: 'castle',
|
||||
name: 'Château Royal',
|
||||
attrs: ['food', 'war'],
|
||||
cost: { food: 3, war: 2 },
|
||||
image: 'chateau.png',
|
||||
description: 'Garantit +1 en Nourriture et +1 en Guerre à chaque tour. +1 supplémentaire sur chaque attribut si l\'objet correspondant (Charrue / Trébuchet) est aussi possédé.'
|
||||
}
|
||||
]
|
||||
|
||||
export function getBuildingById(id) {
|
||||
return BUILDINGS.find(b => b.id === id) ?? null
|
||||
}
|
||||
@@ -7,6 +7,13 @@ const ATTR_COLORS = {
|
||||
diplo: 0x3399ee
|
||||
}
|
||||
|
||||
const ATTR_LABELS = {
|
||||
food: 'Nourriture',
|
||||
war: 'Guerre',
|
||||
health: 'Santé',
|
||||
diplo: 'Diplomatie'
|
||||
}
|
||||
|
||||
// Taille du jeton compact affiché en permanence sur le plateau (avant mise
|
||||
// à l'échelle éventuelle via displayScale — voir constructeur).
|
||||
export const ICON_SIZE = 46
|
||||
@@ -52,13 +59,28 @@ const HOVER_SHOW_DELAY = 80
|
||||
* carte détaillée affichée au survol n'hérite JAMAIS de cette échelle —
|
||||
* elle reste toujours à sa taille réelle pour rester lisible, quel que
|
||||
* soit le contexte d'où elle a été ouverte.
|
||||
* kind: 'item' (défaut) ou 'building'. Un objet a un seul attribut
|
||||
* (`itemData.attr`) et un coût scalaire (`itemData.cost`). Un bâtiment a
|
||||
* DEUX attributs (`itemData.attrs`, tableau) et un coût réparti sur les
|
||||
* deux (`itemData.cost`, objet `{attr: montant}`) — cette classe gère les
|
||||
* deux formes via kind, plutôt que dupliquer tout le composant.
|
||||
* hAnchor: 'left' (défaut) ou 'right'. Détermine de quel côté du jeton
|
||||
* compact la carte détaillée s'étend horizontalement au survol. 'left' =
|
||||
* la carte s'étend vers la DROITE (jeton ancré sur son coin gauche) —
|
||||
* adapté à la boutique d'objets sur le bord GAUCHE du canvas. 'right' =
|
||||
* la carte s'étend vers la GAUCHE (jeton ancré sur son coin droit) —
|
||||
* nécessaire pour la boutique de bâtiments sur le bord DROIT du canvas,
|
||||
* sinon la carte déborderait hors du canvas et son bouton ACHETER
|
||||
* deviendrait injoignable au clic.
|
||||
*/
|
||||
export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1) {
|
||||
constructor(scene, x, y, itemData, status = 'available', affordable = true, displayScale = 1, kind = 'item', hAnchor = 'left') {
|
||||
super(scene, x, y)
|
||||
this.itemData = itemData
|
||||
this.status = status
|
||||
this.affordable = affordable
|
||||
this.kind = kind
|
||||
this.hAnchor = hAnchor
|
||||
this._displayScale = displayScale
|
||||
this._expanded = null
|
||||
this._hideTimer = null
|
||||
@@ -67,8 +89,40 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
scene.add.existing(this)
|
||||
}
|
||||
|
||||
/** Attributs concernés par cet objet/bâtiment, toujours sous forme de tableau. */
|
||||
_attrs() {
|
||||
return this.kind === 'building' ? this.itemData.attrs : [this.itemData.attr]
|
||||
}
|
||||
|
||||
/** Couleur représentative : celle de l'unique attribut pour un objet, ou un
|
||||
* mélange des deux couleurs d'attribut pour un bâtiment. */
|
||||
_attrColor() {
|
||||
const attrs = this._attrs()
|
||||
if (attrs.length === 1) return ATTR_COLORS[attrs[0]] ?? 0x888888
|
||||
const c1 = ATTR_COLORS[attrs[0]] ?? 0x888888
|
||||
const c2 = ATTR_COLORS[attrs[1]] ?? 0x888888
|
||||
const r = Math.round((((c1 >> 16) & 0xff) + ((c2 >> 16) & 0xff)) / 2)
|
||||
const g = Math.round((((c1 >> 8) & 0xff) + ((c2 >> 8) & 0xff)) / 2)
|
||||
const b = Math.round(((c1 & 0xff) + (c2 & 0xff)) / 2)
|
||||
return (r << 16) | (g << 8) | b
|
||||
}
|
||||
|
||||
/** Coût total affiché sur le badge : le coût lui-même pour un objet, la
|
||||
* somme des coûts par attribut pour un bâtiment. */
|
||||
_costTotal() {
|
||||
if (this.kind === 'building') {
|
||||
return Object.values(this.itemData.cost).reduce((sum, c) => sum + c, 0)
|
||||
}
|
||||
return this.itemData.cost
|
||||
}
|
||||
|
||||
/** Clé de texture portrait, préfixée différemment selon le type. */
|
||||
_textureKey() {
|
||||
return this.kind === 'building' ? `building-${this.itemData.id}` : `item-${this.itemData.id}`
|
||||
}
|
||||
|
||||
_buildCompact() {
|
||||
const attrColor = ATTR_COLORS[this.itemData.attr] ?? 0x888888
|
||||
const attrColor = this._attrColor()
|
||||
const dimmed = this.status === 'taken'
|
||||
const S = ICON_SIZE
|
||||
|
||||
@@ -89,7 +143,7 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
bg.fillRoundedRect(-S / 2, -S / 2, S, S, 8)
|
||||
compact.add(bg)
|
||||
|
||||
const textureKey = `item-${this.itemData.id}`
|
||||
const textureKey = this._textureKey()
|
||||
if (this.scene.textures.exists(textureKey)) {
|
||||
const img = this.scene.add.image(0, -3, textureKey)
|
||||
img.setDisplaySize(S - 10, S - 16)
|
||||
@@ -101,13 +155,13 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
border.strokeRoundedRect(-S / 2, -S / 2, S, S, 8)
|
||||
compact.add(border)
|
||||
|
||||
// Badge de coût (coin haut-droit)
|
||||
// Badge de coût (coin haut-droit) — coût total pour un bâtiment
|
||||
const bx = S / 2 - 7, by = -S / 2 + 7
|
||||
const costRing = this.scene.add.graphics()
|
||||
costRing.fillStyle(0x1a0a00, 1)
|
||||
costRing.fillCircle(bx, by, 9)
|
||||
compact.add(costRing)
|
||||
compact.add(this.scene.add.text(bx, by, String(this.itemData.cost), {
|
||||
compact.add(this.scene.add.text(bx, by, String(this._costTotal()), {
|
||||
fontSize: '10px', fontFamily: 'Georgia', fontStyle: 'bold', color: '#ffffff'
|
||||
}).setOrigin(0.5))
|
||||
|
||||
@@ -180,27 +234,33 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
* displayScale du jeton d'où elle a été ouverte (jeton boutique à taille
|
||||
* normale, ou mini-jeton "posé sur la table" pour moi/les adversaires).
|
||||
*
|
||||
* Elle est ancrée sur le coin HAUT-GAUCHE du jeton compact (dont la taille
|
||||
* visuelle dépend de displayScale) plutôt que centrée dessus, pour rester
|
||||
* dans les limites du canvas quel que soit l'emplacement du jeton : elle
|
||||
* s'étend uniquement vers la droite et vers le bas, jamais vers la gauche
|
||||
* (où se trouve le bord du canvas pour la colonne boutique) ni vers le
|
||||
* haut (où pourrait se trouver le bord supérieur du canvas).
|
||||
* Elle est ancrée sur un coin (haut-gauche ou haut-droit selon hAnchor) du
|
||||
* jeton compact (dont la taille visuelle dépend de displayScale) plutôt
|
||||
* que centrée dessus, pour rester dans les limites du canvas quel que
|
||||
* soit l'emplacement du jeton : elle s'étend toujours vers le bas, et
|
||||
* horizontalement à l'opposé du bord de canvas le plus proche (vers la
|
||||
* droite pour la boutique d'objets à gauche, vers la gauche pour la
|
||||
* boutique de bâtiments à droite — voir hAnchor).
|
||||
*/
|
||||
_showExpanded() {
|
||||
if (this._expanded) return
|
||||
|
||||
const isAvailable = this.status === 'available'
|
||||
const cardH = isAvailable ? EXPANDED_H_WITH_BTN : EXPANDED_H
|
||||
// Un bâtiment affiche une ligne de coût détaillé supplémentaire (2 jauges
|
||||
// distinctes) : la carte doit être un peu plus haute pour lui faire de
|
||||
// la place sans écraser la description.
|
||||
const buildingExtraH = this.kind === 'building' ? 18 : 0
|
||||
const cardH = (isAvailable ? EXPANDED_H_WITH_BTN : EXPANDED_H) + buildingExtraH
|
||||
|
||||
// Demi-taille visuelle réelle du jeton compact (après displayScale) :
|
||||
// sert à aligner le coin haut-gauche de la carte détaillée sur le coin
|
||||
// haut-gauche du jeton compact tel qu'il apparaît réellement à l'écran.
|
||||
// sert à aligner le coin du jeton (gauche ou droit selon hAnchor) sur le
|
||||
// coin correspondant de la carte détaillée, tel qu'il apparaît réellement
|
||||
// à l'écran.
|
||||
const iconHalf = (ICON_SIZE * this._displayScale) / 2
|
||||
const dx = EXPANDED_W / 2 - iconHalf
|
||||
const dx = (this.hAnchor === 'right' ? -1 : 1) * (EXPANDED_W / 2 - iconHalf)
|
||||
const dy = cardH / 2 - iconHalf
|
||||
|
||||
const attrColor = ATTR_COLORS[this.itemData.attr] ?? 0x888888
|
||||
const attrColor = this._attrColor()
|
||||
const c = this.scene.add.container(dx, dy)
|
||||
|
||||
const shadow = this.scene.add.graphics()
|
||||
@@ -213,7 +273,7 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
bg.fillRoundedRect(-EXPANDED_W / 2, -cardH / 2, EXPANDED_W, cardH, 12)
|
||||
c.add(bg)
|
||||
|
||||
const textureKey = `item-${this.itemData.id}`
|
||||
const textureKey = this._textureKey()
|
||||
const imgY = -cardH / 2
|
||||
if (this.scene.textures.exists(textureKey)) {
|
||||
const img = this.scene.add.image(0, imgY, textureKey).setOrigin(0.5, 0)
|
||||
@@ -231,7 +291,21 @@ export class ItemBoardToken extends Phaser.GameObjects.Container {
|
||||
color: '#f5e6c8', align: 'center'
|
||||
}).setOrigin(0.5, 0))
|
||||
|
||||
c.add(this.scene.add.text(0, imgY + EXPANDED_IMAGE_H + 36, this.itemData.description, {
|
||||
// Ligne de coût détaillé par attribut, utile surtout pour un bâtiment
|
||||
// (2 jauges distinctes) — le badge de coin haut-droit n'affiche que le
|
||||
// total, insuffisant pour comprendre la répartition réelle du paiement.
|
||||
let descY = imgY + EXPANDED_IMAGE_H + 34
|
||||
if (this.kind === 'building') {
|
||||
const costLabel = Object.entries(this.itemData.cost)
|
||||
.map(([attr, cost]) => `${cost} ${ATTR_LABELS[attr] ?? attr}`)
|
||||
.join(' + ')
|
||||
c.add(this.scene.add.text(0, descY, costLabel, {
|
||||
fontSize: '11px', fontFamily: 'Georgia, serif', fontStyle: 'bold', color: '#d4a732'
|
||||
}).setOrigin(0.5, 0))
|
||||
descY += 18
|
||||
}
|
||||
|
||||
c.add(this.scene.add.text(0, descY, this.itemData.description, {
|
||||
fontSize: '13px', fontFamily: 'Georgia, serif', color: '#e8d9c0',
|
||||
align: 'center', lineSpacing: 4, wordWrap: { width: EXPANDED_W - 20 }
|
||||
}).setOrigin(0.5, 0))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user