feat: 12 character cards with 4 color-coded attributes (food/war/health/diplo)

This commit is contained in:
kevin.lhereec committed 2026-08-17 11:41:46 +02:00
1 parent 1db221e468
commit 38814668e7
7 files changed
+2190 -317

No files matched your search

+161 -69
View File
@@ -1,106 +1,198 @@
import Phaser from 'phaser'
// Couleurs des 4 attributs (identiques au serveur)
const ATTR = {
food: { color: 0xf0c030, label: 'NOU' },
war: { color: 0xdd2222, label: 'GUE' },
health: { color: 0x22aa44, label: 'SAN' },
diplo: { color: 0x3399ee, label: 'DIP' }
}
const CARD_W = 130
const CARD_H = 190
const CORNER = 28 // taille du badge de coin
const RADIUS = 10
export class CardObject extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {object} cardData - { id, name, food, war, health, diplo, teamColor: { hex } }
*/
constructor(scene, x, y, cardData) {
super(scene, x, y)
this.cardData = cardData
this._selected = false
this._baseY = y
this.build()
this._build()
scene.add.existing(this)
}
build() {
// Fond de carte
this.bg = this.scene.add.image(0, 0, 'card-front')
this.add(this.bg)
_build() {
const teamHex = this.cardData.teamColor?.hex ?? 0x888888
// Nom de la carte
this.nameText = this.scene.add.text(0, -60, this.cardData.name, {
fontSize: '12px',
color: '#f5e6c8',
fontFamily: 'Georgia',
wordWrap: { width: 100 },
// --- Fond de carte avec couleur d'équipe ---
const bg = this.scene.add.graphics()
// Ombre portée
bg.fillStyle(0x000000, 0.4)
bg.fillRoundedRect(4, 4, CARD_W, CARD_H, RADIUS)
// Corps principal (couleur équipe assombrie)
bg.fillStyle(this._darken(teamHex, 0.3))
bg.fillRoundedRect(0, 0, CARD_W, CARD_H, RADIUS)
// Bordure couleur équipe
bg.lineStyle(2, teamHex, 1)
bg.strokeRoundedRect(0, 0, CARD_W, CARD_H, RADIUS)
// Zone portrait (rectangle foncé central)
bg.fillStyle(0x000000, 0.35)
bg.fillRoundedRect(8, 28, CARD_W - 16, CARD_H - 80, 6)
bg.x = -CARD_W / 2
bg.y = -CARD_H / 2
this.add(bg)
// --- Nom du personnage (haut, centré) ---
const nameText = this.scene.add.text(0, -CARD_H / 2 + 9, this.cardData.name, {
fontSize: '13px',
fontFamily: 'Georgia, serif',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 3,
fontStyle: 'bold'
}).setOrigin(0.5, 0)
this.add(nameText)
// --- Initiale du personnage (zone portrait) ---
const initial = this.scene.add.text(0, -CARD_H / 2 + 75, this.cardData.name[0], {
fontSize: '52px',
fontFamily: 'Georgia, serif',
color: '#ffffff',
alpha: 0.15
}).setOrigin(0.5, 0.5)
this.add(initial)
// --- Description courte (bas de carte) ---
const desc = this.scene.add.text(0, CARD_H / 2 - 22, this._shortDesc(), {
fontSize: '8px',
fontFamily: 'Georgia, serif',
color: '#cccccc',
wordWrap: { width: CARD_W - 16 },
align: 'center'
}).setOrigin(0.5)
this.add(this.nameText)
}).setOrigin(0.5, 1)
this.add(desc)
// Valeur/effet
this.valueText = this.scene.add.text(0, 10, `+${this.cardData.points} pts`, {
fontSize: '18px',
color: '#d4a732',
fontFamily: 'Georgia'
}).setOrigin(0.5)
this.add(this.valueText)
// --- 4 badges d'attributs dans les coins ---
this._addCornerBadge('food', -CARD_W / 2 + 2, -CARD_H / 2 + 2, 'topLeft')
this._addCornerBadge('war', CARD_W / 2 - 2, -CARD_H / 2 + 2, 'topRight')
this._addCornerBadge('health', -CARD_W / 2 + 2, CARD_H / 2 - 2, 'bottomLeft')
this._addCornerBadge('diplo', CARD_W / 2 - 2, CARD_H / 2 - 2, 'bottomRight')
// Type de carte
this.typeText = this.scene.add.text(0, 50, this.cardData.type, {
fontSize: '10px',
color: '#a08060',
fontFamily: 'Georgia'
}).setOrigin(0.5)
this.add(this.typeText)
// Interactivité
this.setSize(120, 180)
// --- Interactivité ---
this.setSize(CARD_W, CARD_H)
this.setInteractive({ cursor: 'pointer' })
this.on('pointerover', this.onHover, this)
this.on('pointerout', this.onOut, this)
this.on('pointerdown', this.onDown, this)
this.on('pointerover', this._onHover, this)
this.on('pointerout', this._onOut, this)
this.on('pointerdown', this._onDown, this)
}
onHover() {
if (!this._selected) {
this.scene.tweens.add({
targets: this,
y: this.y - 15,
duration: 150,
ease: 'Power2'
})
/**
* Badge carré coloré dans un coin, avec la valeur de l'attribut
* @param {'food'|'war'|'health'|'diplo'} attr
* @param {number} bx position x relative au centre du container (coin du badge)
* @param {number} by position y relative au centre du container (coin du badge)
* @param {'topLeft'|'topRight'|'bottomLeft'|'bottomRight'} corner
*/
_addCornerBadge(attr, bx, by, corner) {
const { color, label } = ATTR[attr]
const value = this.cardData[attr]
const S = CORNER
// Calculer l'origine du rectangle selon le coin
let rx = bx, ry = by
if (corner === 'topRight' || corner === 'bottomRight') rx = bx - S
if (corner === 'bottomLeft' || corner === 'bottomRight') ry = by - S
const g = this.scene.add.graphics()
g.fillStyle(color, 0.9)
// Coins arrondis uniquement vers l'intérieur de la carte
const cornerRadii = {
topLeft: { tl: RADIUS, tr: 2, bl: 2, br: 4 },
topRight: { tl: 2, tr: RADIUS, bl: 4, br: 2 },
bottomLeft: { tl: 2, tr: 4, bl: RADIUS, br: 2 },
bottomRight: { tl: 4, tr: 2, bl: 2, br: RADIUS }
}
g.fillRoundedRect(rx, ry, S, S, cornerRadii[corner])
this.add(g)
// Valeur (grand, centré dans le badge)
const cx = rx + S / 2
const cy = ry + S / 2 - 3
const valText = this.scene.add.text(cx, cy, String(value), {
fontSize: '14px',
fontFamily: 'Georgia, serif',
fontStyle: 'bold',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 2
}).setOrigin(0.5, 0.5)
this.add(valText)
// Label abrégé (petit, en bas du badge)
const lblText = this.scene.add.text(cx, cy + 8, label, {
fontSize: '6px',
fontFamily: 'Arial, sans-serif',
color: '#ffffff',
alpha: 0.85
}).setOrigin(0.5, 0.5)
this.add(lblText)
}
onOut() {
if (!this._selected) {
this.scene.tweens.add({
targets: this,
y: this.y + 15,
duration: 150,
ease: 'Power2'
})
}
// --- Interactions ---
_onHover() {
if (this._selected) return
this.scene.tweens.add({ targets: this, y: this._baseY - 18, duration: 120, ease: 'Power2' })
this.setDepth(10)
}
onDown() {
this._selected = !this._selected
_onOut() {
if (this._selected) return
this.scene.tweens.add({ targets: this, y: this._baseY, duration: 120, ease: 'Power2' })
this.setDepth(0)
}
_onDown() {
if (this._selected) {
this.deselect()
} else {
this.select()
this.emit('select', this)
} else {
this.deselect()
}
}
select() {
this._selected = true
this.bg.setTint(0xd4a732)
this.scene.tweens.add({
targets: this,
y: this.y - 20,
duration: 150,
ease: 'Power2'
})
this.setDepth(20)
this.scene.tweens.add({ targets: this, y: this._baseY - 28, duration: 150, ease: 'Back.Out' })
}
deselect() {
this._selected = false
this.bg.clearTint()
this.scene.tweens.add({
targets: this,
y: this.y + 20,
duration: 150,
ease: 'Power2'
})
this.setDepth(0)
this.scene.tweens.add({ targets: this, y: this._baseY, duration: 150, ease: 'Power2' })
}
// --- Utilitaires ---
_darken(hex, factor) {
const r = Math.floor(((hex >> 16) & 0xff) * factor)
const g = Math.floor(((hex >> 8) & 0xff) * factor)
const b = Math.floor(( hex & 0xff) * factor)
return (r << 16) | (g << 8) | b
}
_shortDesc() {
const d = this.cardData.description || ''
return d.length > 60 ? d.substring(0, 57) + '…' : d
}
}
+243 -91
View File
@@ -1,99 +1,160 @@
import Phaser from 'phaser'
import { CardObject } from '../objects/CardObject'
// Couleurs équipes (synchronisé avec le serveur)
const TEAM_COLORS = {
0: 0xcc2200,
1: 0x1155cc,
2: 0x1a7a2e,
3: 0xb8860b
}
// Couleurs attributs
const ATTR_COLORS = {
food: 0xf0c030,
war: 0xdd2222,
health: 0x22aa44,
diplo: 0x3399ee
}
const ATTR_LABELS = {
food: 'Nourriture',
war: 'Guerre',
health: 'Santé',
diplo: 'Diplomatie'
}
export class GameScene extends Phaser.Scene {
constructor() {
super('GameScene')
this.hand = []
this.handCards = []
this.selectedCard = null
this.playedCards = []
this.resolveObjects = []
}
create() {
this.socket = this.registry.get('socket')
this.store = this.registry.get('store')
this.roomId = this.registry.get('roomId')
this.socket = this.registry.get('socket')
this.store = this.registry.get('store')
this.roomId = this.registry.get('roomId')
this.createTable()
this.createHand()
this.createPlayButton()
this.setupSocketListeners()
this._buildTable()
this._buildPlayButton()
this._setupSocketListeners()
// Demander l'état initial
this.socket.emit('request-state', { roomId: this.roomId })
}
createTable() {
// ─── Construction du plateau ─────────────────────────────────────────────────
_buildTable() {
const { width, height } = this.scale
// Zone centrale de jeu
this.add.rectangle(width / 2, height / 2, width * 0.6, height * 0.4, 0x1a0800, 0.8)
.setStrokeStyle(1, 0xd4a73240)
// Fond parchemin
this.add.rectangle(width / 2, height / 2, width, height, 0x0d0500)
// Label tour
this.turnText = this.add.text(width / 2, 30, 'Tour 1/10', {
fontSize: '18px',
color: '#d4a732',
fontFamily: 'Georgia'
// Zone de jeu centrale
this.add.rectangle(width / 2, height / 2 - 60, width * 0.7, height * 0.38, 0x1a0800, 0.7)
.setStrokeStyle(1, 0xd4a73230)
// Titre du tour
this.turnText = this.add.text(width / 2, 18, 'Tour 1 / 12', {
fontSize: '16px',
fontFamily: 'Georgia',
color: '#d4a732'
}).setOrigin(0.5)
// Zone cartes jouées (4 emplacements)
this.playZones = []
const zoneY = height / 2
const startX = width / 2 - 180
for (let i = 0; i < 4; i++) {
const x = startX + i * 120
const zone = this.add.rectangle(x, zoneY, 110, 170, 0x000000, 0)
.setStrokeStyle(1, 0xd4a73250)
this.playZones.push(zone)
// Phase courante
this.phaseText = this.add.text(width / 2, 40, 'Sélectionnez une carte', {
fontSize: '13px',
fontFamily: 'Georgia',
color: '#a08060'
}).setOrigin(0.5)
this.add.text(x, zoneY + 100, `J${i + 1}`, {
fontSize: '12px',
color: '#a08060',
fontFamily: 'Georgia'
// 4 emplacements de cartes jouées (zone centrale)
this.playSlots = []
const slotY = height / 2 - 60
const totalW = 4 * 140
const startX = width / 2 - totalW / 2 + 70
for (let i = 0; i < 4; i++) {
const x = startX + i * 140
const slot = this.add.graphics()
slot.lineStyle(1, 0xd4a73240)
slot.strokeRoundedRect(-65, -95, 130, 190, 8)
slot.x = x
slot.y = slotY
this.playSlots.push({ x, y: slotY, graphics: slot })
// Label joueur
this.add.text(x, slotY + 108, `J${i + 1}`, {
fontSize: '11px',
fontFamily: 'Georgia',
color: '#4a3020'
}).setOrigin(0.5)
}
// Légende attributs (coin en bas à droite)
this._buildAttrLegend(width - 10, height - 10)
}
createHand() {
const { width, height } = this.scale
const handY = height - 110
_buildAttrLegend(x, y) {
const entries = [
{ attr: 'food', label: '🟡 Nourriture — haut gauche' },
{ attr: 'war', label: '🔴 Guerre — haut droite' },
{ attr: 'health', label: '🟢 Santé — bas gauche' },
{ attr: 'diplo', label: '🔵 Diplomatie — bas droite' }
]
// Vider l'ancienne main
this.hand.forEach(c => c.destroy())
this.hand = []
const cards = this.store.hand
const totalWidth = cards.length * 130
const startX = width / 2 - totalWidth / 2 + 60
cards.forEach((cardData, i) => {
const card = new CardObject(this, startX + i * 130, handY, cardData)
card.on('select', this.onCardSelected, this)
this.hand.push(card)
entries.forEach((e, i) => {
this.add.text(x, y - i * 16, e.label, {
fontSize: '10px',
fontFamily: 'Georgia',
color: '#6a5040'
}).setOrigin(1, 1)
})
}
createPlayButton() {
_buildPlayButton() {
const { width, height } = this.scale
this.playBtn = this.add.text(width / 2, height - 30, '[ JOUER LA CARTE ]', {
fontSize: '16px',
this.playBtn = this.add.text(width / 2, height - 18, '[ JOUER LA CARTE ]', {
fontSize: '15px',
fontFamily: 'Georgia',
color: '#1a0a00',
backgroundColor: '#d4a732',
padding: { x: 20, y: 8 },
fontFamily: 'Georgia'
padding: { x: 20, y: 8 }
})
.setOrigin(0.5)
.setAlpha(0.35)
.setInteractive({ cursor: 'pointer' })
.setAlpha(0.4)
.on('pointerdown', this.playSelectedCard, this)
.on('pointerover', () => { if (this.selectedCard) this.playBtn.setAlpha(1) })
.on('pointerout', () => { if (!this.selectedCard) this.playBtn.setAlpha(0.4) })
.on('pointerdown', this._playSelectedCard, this)
}
onCardSelected(card) {
// Désélectionner la précédente
// ─── Main du joueur ──────────────────────────────────────────────────────────
_buildHand(cards) {
const { width, height } = this.scale
// Vider l'ancienne main
this.handCards.forEach(c => c.destroy())
this.handCards = []
if (!cards || cards.length === 0) return
const handY = height - 120
const spacing = Math.min(150, (width * 0.85) / cards.length)
const totalW = spacing * (cards.length - 1)
const startX = width / 2 - totalW / 2
cards.forEach((cardData, i) => {
const card = new CardObject(this, startX + i * spacing, handY, cardData)
card.on('select', this._onCardSelected, this)
this.handCards.push(card)
})
}
_onCardSelected(card) {
// Désélectionner l'ancienne
if (this.selectedCard && this.selectedCard !== card) {
this.selectedCard.deselect()
}
@@ -102,7 +163,7 @@ export class GameScene extends Phaser.Scene {
this.playBtn.setAlpha(1)
}
playSelectedCard() {
_playSelectedCard() {
if (!this.selectedCard || this.store.phase !== 'selecting') return
this.socket.emit('play-card', {
@@ -110,55 +171,146 @@ export class GameScene extends Phaser.Scene {
cardId: this.selectedCard.cardData.id
})
this.selectedCard.setAlpha(0.5)
this.selectedCard.setAlpha(0.4)
this.selectedCard = null
this.store.clearSelection()
this.playBtn.setAlpha(0.4)
this.playBtn.setAlpha(0.35)
this.phaseText.setText('En attente des autres joueurs...')
}
setupSocketListeners() {
// ─── Résolution visuelle ─────────────────────────────────────────────────────
_showPlayedCards(playedCards) {
// Nettoyer
this.resolveObjects.forEach(o => o.destroy())
this.resolveObjects = []
playedCards.forEach((entry, i) => {
const slot = this.playSlots[i]
if (!slot) return
const teamColor = TEAM_COLORS[entry.teamIndex] ?? 0x888888
// Mini carte de résolution
const g = this.add.graphics()
g.fillStyle(this._darken(teamColor, 0.3))
g.fillRoundedRect(-55, -85, 110, 170, 6)
g.lineStyle(2, teamColor)
g.strokeRoundedRect(-55, -85, 110, 170, 6)
g.x = slot.x
g.y = slot.y
this.resolveObjects.push(g)
// Nom du joueur
const pName = this.add.text(slot.x, slot.y - 72, entry.playerName, {
fontSize: '10px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
}).setOrigin(0.5)
this.resolveObjects.push(pName)
// Nom de la carte
const cName = this.add.text(slot.x, slot.y - 56, entry.cardName, {
fontSize: '13px', fontFamily: 'Georgia', color: '#d4a732', fontStyle: 'bold'
}).setOrigin(0.5)
this.resolveObjects.push(cName)
// 4 attributs
const attrs = [
{ key: 'food', label: 'NOU', cx: slot.x - 28, cy: slot.y - 20 },
{ key: 'war', label: 'GUE', cx: slot.x + 28, cy: slot.y - 20 },
{ key: 'health', label: 'SAN', cx: slot.x - 28, cy: slot.y + 40 },
{ key: 'diplo', label: 'DIP', cx: slot.x + 28, cy: slot.y + 40 }
]
attrs.forEach(({ key, label, cx, cy }) => {
const c = this.add.graphics()
c.fillStyle(ATTR_COLORS[key], 0.85)
c.fillRoundedRect(-20, -18, 40, 36, 4)
c.x = cx
c.y = cy
this.resolveObjects.push(c)
const v = this.add.text(cx, cy - 4, String(entry[key]), {
fontSize: '14px', fontFamily: 'Georgia', fontStyle: 'bold',
color: '#ffffff', stroke: '#000000', strokeThickness: 2
}).setOrigin(0.5)
this.resolveObjects.push(v)
const l = this.add.text(cx, cy + 10, label, {
fontSize: '7px', fontFamily: 'Arial', color: '#ffffff'
}).setOrigin(0.5)
this.resolveObjects.push(l)
})
})
}
_showAttrResults(attrResults) {
const { width, height } = this.scale
const attrs = ['food', 'war', 'health', 'diplo']
const boxY = height / 2 + 80
attrs.forEach((attr, i) => {
const r = attrResults[attr]
const x = width / 2 - 300 + i * 200
const g = this.add.graphics()
g.fillStyle(ATTR_COLORS[attr], 0.15)
g.fillRoundedRect(-90, -20, 180, 40, 6)
g.lineStyle(1, ATTR_COLORS[attr], 0.6)
g.strokeRoundedRect(-90, -20, 180, 40, 6)
g.x = x
g.y = boxY
this.resolveObjects.push(g)
const label = ATTR_LABELS[attr]
const winText = r.winner === 'tie'
? `Égalité`
: `${r.winner} +${r.value}`
const t1 = this.add.text(x, boxY - 8, label, {
fontSize: '11px', fontFamily: 'Georgia', color: '#' + ATTR_COLORS[attr].toString(16).padStart(6, '0')
}).setOrigin(0.5)
this.resolveObjects.push(t1)
const t2 = this.add.text(x, boxY + 8, winText, {
fontSize: '12px', fontFamily: 'Georgia', color: '#ffffff', fontStyle: 'bold'
}).setOrigin(0.5)
this.resolveObjects.push(t2)
})
}
// ─── Socket listeners ────────────────────────────────────────────────────────
_setupSocketListeners() {
this.socket.on('game-state', (state) => {
this.store.updateState(state)
this.turnText.setText(`Tour ${state.currentTurn}/${state.maxTurns}`)
this.turnText.setText(`Tour ${state.currentTurn} / ${state.maxTurns}`)
if (state.phase === 'selecting') {
this.createHand()
this.resolveObjects.forEach(o => o.destroy())
this.resolveObjects = []
this.phaseText.setText('Sélectionnez une carte')
this._buildHand(state.hand)
}
if (state.phase === 'resolve') {
this.showPlayedCards(state.playedCards)
if (state.phase === 'waiting') {
this.phaseText.setText('En attente des autres joueurs...')
}
})
this.socket.on('round-result', (result) => {
this.showRoundResult(result)
this.socket.on('round-result', ({ playedCards, attrResults }) => {
this.phaseText.setText('Résolution du tour...')
this._showPlayedCards(playedCards)
this._showAttrResults(attrResults)
})
}
showPlayedCards(played) {
played.forEach((entry, i) => {
if (!this.playZones[i]) return
const zone = this.playZones[i]
this.add.text(zone.x, zone.y, entry.cardName, {
fontSize: '13px',
color: '#f5e6c8',
fontFamily: 'Georgia',
wordWrap: { width: 100 }
}).setOrigin(0.5).setDepth(10)
})
}
// ─── Utilitaires ─────────────────────────────────────────────────────────────
showRoundResult(result) {
const { width, height } = this.scale
const txt = this.add.text(width / 2, height / 2 - 100, result.message, {
fontSize: '20px',
color: '#d4a732',
fontFamily: 'Georgia',
stroke: '#000000',
strokeThickness: 4
}).setOrigin(0.5).setDepth(20)
this.time.delayedCall(2500, () => txt.destroy())
_darken(hex, factor) {
const r = Math.floor(((hex >> 16) & 0xff) * factor)
const g = Math.floor(((hex >> 8) & 0xff) * factor)
const b = Math.floor(( hex & 0xff) * factor)
return (r << 16) | (g << 8) | b
}
update() {}