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

+109 -99
View File
@@ -1,22 +1,32 @@
const { dealHands, createDeck } = require('./cards')
const { dealHands } = require('./cards')
const MAX_PLAYERS = 4
const MAX_TURNS = 10
const HAND_SIZE = 5
const MAX_TURNS = 12 // 12 tours (1 par personnage potentiellement)
const HAND_SIZE = 4 // 4 cartes en main au départ
/**
* Logique de résolution par tour :
* - Chaque joueur joue une carte simultanément
* - On compare les 4 attributs (food, war, health, diplo) entre tous les joueurs
* - Pour chaque attribut, le joueur avec la valeur la plus élevée marque des points
* - En cas d'égalité, personne ne marque sur cet attribut
* - Les points marqués = valeur de l'attribut gagnant
*/
class Room {
constructor(id) {
this.id = id
this.players = [] // { socketId, name, score, hand, protected }
this.phase = 'lobby' // lobby | selecting | waiting | resolve | end
this.id = id
this.players = [] // { socketId, name, teamIndex, score, hand }
this.phase = 'lobby' // lobby | selecting | waiting | resolve | end
this.currentTurn = 0
this.maxTurns = MAX_TURNS
this.maxTurns = MAX_TURNS
this.pendingPlays = new Map() // socketId -> card
this.deck = []
}
get playerCount() { return this.players.length }
// ─── Gestion des joueurs ────────────────────────────────────────────────────
addPlayer(socketId, name) {
if (this.players.length >= MAX_PLAYERS) return false
if (this.players.find(p => p.socketId === socketId)) return true
@@ -24,9 +34,9 @@ class Room {
this.players.push({
socketId,
name,
teamIndex: this.players.length, // 0=Écarlate 1=Azur 2=Émeraude 3=Or
score: 0,
hand: [],
protected: false
hand: []
})
return true
}
@@ -35,29 +45,38 @@ class Room {
this.players = this.players.filter(p => p.socketId !== socketId)
}
// ─── Démarrage ──────────────────────────────────────────────────────────────
startGame() {
const { hands, remaining } = dealHands(this.players.length, HAND_SIZE)
this.deck = remaining
const hands = dealHands(this.players.length, HAND_SIZE)
this.players.forEach((p, i) => { p.hand = hands[i] })
this.currentTurn = 1
this.phase = 'selecting'
this.pendingPlays.clear()
}
// ─── Phase de jeu ───────────────────────────────────────────────────────────
playCard(socketId, cardId) {
if (this.phase !== 'selecting') return { ok: false, error: 'Ce n\'est pas la phase de jeu' }
if (this.pendingPlays.has(socketId)) return { ok: false, error: 'Tu as déjà joué ce tour' }
if (this.phase !== 'selecting')
return { ok: false, error: 'Ce n\'est pas la phase de sélection' }
if (this.pendingPlays.has(socketId))
return { ok: false, error: 'Tu as déjà joué une carte ce tour' }
const player = this.players.find(p => p.socketId === socketId)
if (!player) return { ok: false, error: 'Joueur introuvable' }
const cardIndex = player.hand.findIndex(c => c.id === cardId)
if (cardIndex === -1) return { ok: false, error: 'Carte non trouvée dans ta main' }
if (cardIndex === -1) return { ok: false, error: 'Carte absente de ta main' }
const card = player.hand.splice(cardIndex, 1)[0]
const [card] = player.hand.splice(cardIndex, 1)
this.pendingPlays.set(socketId, { player, card })
this.phase = 'waiting'
// Si tous ont joué → passer en "waiting" visible côté client
if (this.pendingPlays.size === this.players.length) {
this.phase = 'waiting'
}
return { ok: true }
}
@@ -65,119 +84,110 @@ class Room {
return this.pendingPlays.size === this.players.length
}
// ─── Résolution du tour ─────────────────────────────────────────────────────
resolveRound() {
const plays = [...this.pendingPlays.values()]
const playedCards = plays.map(p => ({ name: p.player.name, cardName: p.card.name }))
// Réinitialiser protection
this.players.forEach(p => { p.protected = false })
// Cartes jouées (pour affichage)
const playedCards = plays.map(({ player, card }) => ({
playerName: player.name,
teamIndex: player.teamIndex,
cardId: card.id,
cardName: card.name,
food: card.food,
war: card.war,
health: card.health,
diplo: card.diplo
}))
// 1. Appliquer les effets de protection (Enchanteur)
plays.forEach(({ player, card }) => {
if (card.effect === 'protect') {
player.protected = true
// Résolution attribut par attribut
const attrs = ['food', 'war', 'health', 'diplo']
const pointsGained = {} // socketId -> { food, war, health, diplo, total }
plays.forEach(({ player }) => {
pointsGained[player.socketId] = { food: 0, war: 0, health: 0, diplo: 0, total: 0 }
})
const attrResults = {} // attr -> { winner: name | 'tie', value }
attrs.forEach(attr => {
const max = Math.max(...plays.map(p => p.card[attr]))
const winners = plays.filter(p => p.card[attr] === max)
if (winners.length === 1) {
// Un seul gagnant
const { player } = winners[0]
pointsGained[player.socketId][attr] = max
pointsGained[player.socketId].total += max
player.score += max
attrResults[attr] = { winner: player.name, value: max }
} else {
// Égalité → personne ne marque
attrResults[attr] = { winner: 'tie', value: max, tiedPlayers: winners.map(w => w.player.name) }
}
})
// 2. Calculer les points de base et effets
const messages = []
plays.forEach(({ player, card }) => {
// Points de base
player.score += card.points
if (!card.effect) return
const [effectType, valueStr] = card.effect.split(':')
const value = parseInt(valueStr) || 0
switch (effectType) {
case 'steal': {
// Voler des points au joueur le plus riche non protégé
const targets = this.players
.filter(p => p.socketId !== player.socketId && !p.protected && p.score > 0)
.sort((a, b) => b.score - a.score)
if (targets.length > 0) {
const stolen = Math.min(value, targets[0].score)
targets[0].score -= stolen
player.score += stolen
messages.push(`${player.name} vole ${stolen} pts à ${targets[0].name}`)
}
break
}
case 'bonus': {
// Bonus si le joueur a le plus de points de Royaume ce tour
const myRoyaume = plays
.filter(p => p.player.socketId === player.socketId && p.card.type === 'Royaume')
.reduce((sum, p) => sum + p.card.points, 0)
if (myRoyaume > 0) {
player.score += value
messages.push(`${player.name} gagne +${value} pts bonus (Commerce)`)
}
break
}
case 'double': {
// Double les points gagnés CE tour (uniquement la carte jouée)
player.score += card.points
messages.push(`${player.name} double ses gains (Alchimiste)`)
break
}
case 'draw': {
// Piocher une carte supplémentaire
if (this.deck.length > 0) {
player.hand.push(this.deck.pop())
messages.push(`${player.name} pioche une carte bonus`)
}
break
}
}
// Message de résumé
const lines = attrs.map(attr => {
const r = attrResults[attr]
const label = { food: 'Nourriture', war: 'Guerre', health: 'Santé', diplo: 'Diplomatie' }[attr]
if (r.winner === 'tie') return `${label} : Égalité (${r.tiedPlayers.join(' & ')}) — personne ne marque`
return `${label} : ${r.winner} gagne ${r.value} pts`
})
const message = messages.length > 0 ? messages.join(' | ') : 'Tour résolu !'
this.pendingPlays.clear()
this.phase = 'resolve'
return { playedCards, message }
return {
playedCards,
attrResults,
pointsGained,
message: lines.join('\n')
}
}
// ─── Tour suivant ────────────────────────────────────────────────────────────
nextTurn() {
this.currentTurn++
this.phase = 'selecting'
// Distribuer une nouvelle carte si la main est vide
this.players.forEach(p => {
if (p.hand.length === 0 && this.deck.length >= HAND_SIZE) {
p.hand = this.deck.splice(0, HAND_SIZE)
} else if (p.hand.length < 2 && this.deck.length > 0) {
p.hand.push(this.deck.pop())
}
})
if (this.isGameOver()) {
this.phase = 'end'
return
}
this.phase = 'selecting'
// Si un joueur n'a plus de cartes en main, on lui repioche depuis son deck perso
// (dans cette version simple la main se vide naturellement)
}
isGameOver() {
return this.currentTurn >= this.maxTurns
// Partie terminée quand tous les joueurs n'ont plus de cartes OU après maxTurns
const allEmpty = this.players.every(p => p.hand.length === 0)
return allEmpty || this.currentTurn >= this.maxTurns
}
// ─── Données publiques ───────────────────────────────────────────────────────
getPlayersInfo() {
return this.players.map(p => ({
name: p.name,
score: p.score,
handSize: p.hand.length
name: p.name,
teamIndex: p.teamIndex,
score: p.score,
handSize: p.hand.length
}))
}
/** État envoyé à UN joueur spécifique (inclut sa main) */
getStateFor(socketId) {
const player = this.players.find(p => p.socketId === socketId)
return {
players: this.getPlayersInfo(),
players: this.getPlayersInfo(),
currentTurn: this.currentTurn,
maxTurns: this.maxTurns,
phase: this.phase,
hand: player ? player.hand : []
maxTurns: this.maxTurns,
phase: this.phase,
hand: player ? player.hand : [],
myTeamIndex: player ? player.teamIndex : 0
}
}
}
+160 -34
View File
@@ -1,31 +1,161 @@
// Deck de base de Regnum
// Types: Royaume (points fixes), Armée (attaque), Commerce (multiplicateur), Magie (effets spéciaux)
/**
* Regnum - Définition des 12 personnages
*
* Chaque carte possède 4 attributs (valeurs de 1 à 6) :
* food (🟡 Nourriture) - coin haut gauche - jaune
* war (🔴 Guerre) - coin haut droite - rouge
* health(🟢 Santé) - coin bas gauche - vert
* diplo (🔵 Diplomatie) - coin bas droite - bleu
*
* Toutes les équipes ont exactement le même deck de 12 cartes,
* seule la couleur d'équipe change visuellement.
*/
const BASE_DECK = [
// === ROYAUME ===
{ id: 'r1', name: 'Village', type: 'Royaume', points: 2, effect: null },
{ id: 'r2', name: 'Château', type: 'Royaume', points: 5, effect: null },
{ id: 'r3', name: 'Cathédrale', type: 'Royaume', points: 4, effect: null },
{ id: 'r4', name: 'Forteresse', type: 'Royaume', points: 3, effect: null },
{ id: 'r5', name: 'Palais Royal', type: 'Royaume', points: 7, effect: null },
// === ARMÉE ===
{ id: 'a1', name: 'Chevaliers', type: 'Armée', points: 3, effect: 'steal:1' },
{ id: 'a2', name: 'Archers', type: 'Armée', points: 2, effect: 'steal:1' },
{ id: 'a3', name: 'Catapulte', type: 'Armée', points: 1, effect: 'steal:2' },
{ id: 'a4', name: 'Légion', type: 'Armée', points: 4, effect: 'steal:2' },
// === COMMERCE ===
{ id: 'c1', name: 'Marché', type: 'Commerce', points: 2, effect: 'bonus:1' },
{ id: 'c2', name: 'Port', type: 'Commerce', points: 3, effect: 'bonus:2' },
{ id: 'c3', name: 'Route de soie', type: 'Commerce', points: 5, effect: 'bonus:1' },
// === MAGIE ===
{ id: 'm1', name: 'Oracle', type: 'Magie', points: 1, effect: 'draw:1' },
{ id: 'm2', name: 'Enchanteur', type: 'Magie', points: 2, effect: 'protect' },
{ id: 'm3', name: 'Alchimiste', type: 'Magie', points: 3, effect: 'double' },
const CHARACTERS = [
{
id: 'queen',
name: 'Reine',
food: 2,
war: 2,
health: 3,
diplo: 5,
description: 'Maîtresse de la diplomatie, elle tisse des alliances avec dextérité.'
},
{
id: 'prince',
name: 'Prince',
food: 2,
war: 4,
health: 2,
diplo: 4,
description: 'Héritier du trône, équilibré entre le glaive et la parole.'
},
{
id: 'butcher',
name: 'Boucher',
food: 5,
war: 3,
health: 1,
diplo: 1,
description: 'Maître des viandes, il nourrit le peuple mais soigne peu.'
},
{
id: 'doctor',
name: 'Médecin',
food: 2,
war: 1,
health: 6,
diplo: 3,
description: 'Expert en guérison, indispensable à la survie du royaume.'
},
{
id: 'apothecary',
name: 'Apothicaire',
food: 3,
war: 1,
health: 5,
diplo: 3,
description: 'Prépare potions et remèdes, soutien précieux en temps de crise.'
},
{
id: 'jester',
name: 'Fou du Roi',
food: 1,
war: 1,
health: 2,
diplo: 6,
description: 'Sa sagesse cachée derrière la bouffonnerie fait des miracles diplomatiques.'
},
{
id: 'assassin',
name: 'Assassin',
food: 1,
war: 6,
health: 1,
diplo: 2,
description: 'Frappe dans l\'ombre, inarrêtable au combat mais inutile ailleurs.'
},
{
id: 'peasant',
name: 'Paysan',
food: 6,
war: 2,
health: 2,
diplo: 1,
description: 'Nourrit tout le royaume, force vitale de toute civilisation.'
},
{
id: 'executioner',
name: 'Bourreau',
food: 2,
war: 5,
health: 1,
diplo: 2,
description: 'Bras armé de la justice royale, redouté de tous.'
},
{
id: 'knight',
name: 'Chevalier',
food: 3,
war: 5,
health: 2,
diplo: 2,
description: 'Défenseur du royaume, vaillant sur le champ de bataille.'
},
{
id: 'merchant',
name: 'Marchand',
food: 4,
war: 1,
health: 2,
diplo: 5,
description: 'Ses routes commerciales enrichissent et lient les royaumes.'
},
{
id: 'bishop',
name: 'Évêque',
food: 2,
war: 1,
health: 4,
diplo: 5,
description: 'Voix de l\'Église, il guérit les âmes et négocie la paix.'
}
]
/**
* Couleurs des 4 équipes (teinte principale de la carte)
*/
const TEAM_COLORS = {
0: { name: 'Écarlate', hex: 0xcc2200, css: '#cc2200' },
1: { name: 'Azur', hex: 0x1155cc, css: '#1155cc' },
2: { name: 'Émeraude', hex: 0x1a7a2e, css: '#1a7a2e' },
3: { name: 'Or', hex: 0xb8860b, css: '#b8860b' }
}
/**
* Attributs visuels fixes (couleur de chaque coin)
*/
const ATTR_COLORS = {
food: { hex: 0xf0c030, css: '#f0c030', label: 'Nourriture', corner: 'topLeft' },
war: { hex: 0xdd2222, css: '#dd2222', label: 'Guerre', corner: 'topRight' },
health: { hex: 0x22aa44, css: '#22aa44', label: 'Santé', corner: 'bottomLeft' },
diplo: { hex: 0x3399ee, css: '#3399ee', label: 'Diplomatie', corner: 'bottomRight' }
}
/**
* Retourne le deck complet (les 12 personnages) pour une équipe donnée
*/
function getDeckForTeam(teamIndex) {
return CHARACTERS.map(c => ({
...c,
teamIndex,
teamColor: TEAM_COLORS[teamIndex]
}))
}
/**
* Distribue une main de N cartes par joueur depuis un deck mélangé
*/
function shuffle(arr) {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
@@ -35,17 +165,13 @@ function shuffle(arr) {
return a
}
function createDeck() {
return shuffle([...BASE_DECK, ...BASE_DECK]) // 2 exemplaires
}
function dealHands(playerCount, handSize = 5) {
const deck = createDeck()
function dealHands(playerCount, handSize = 4) {
const hands = []
for (let i = 0; i < playerCount; i++) {
hands.push(deck.splice(0, handSize))
const deck = shuffle(getDeckForTeam(i))
hands.push(deck.slice(0, handSize))
}
return { hands, remaining: deck }
return hands
}
module.exports = { createDeck, dealHands, BASE_DECK }
module.exports = { CHARACTERS, TEAM_COLORS, ATTR_COLORS, getDeckForTeam, dealHands }
+42 -24
View File
@@ -1,7 +1,7 @@
const express = require('express')
const http = require('http')
const http = require('http')
const { Server } = require('socket.io')
const cors = require('cors')
const cors = require('cors')
const { RoomManager } = require('./game/RoomManager')
const app = express()
@@ -16,8 +16,9 @@ const io = new Server(server, {
const rooms = new RoomManager()
io.on('connection', (socket) => {
console.log(`[+] Connexion: ${socket.id}`)
console.log(`[+] ${socket.id}`)
// ─── Rejoindre une salle ─────────────────────────────────────────────────────
socket.on('join-room', ({ roomId, playerName }) => {
const room = rooms.getOrCreate(roomId)
const joined = room.addPlayer(socket.id, playerName)
@@ -28,13 +29,14 @@ io.on('connection', (socket) => {
}
socket.join(roomId)
socket.data.roomId = roomId
socket.data.roomId = roomId
socket.data.playerName = playerName
io.to(roomId).emit('room-update', { players: room.getPlayersInfo() })
console.log(`[room:${roomId}] ${playerName} rejoint (${room.playerCount}/4)`)
console.log(`[${roomId}] ${playerName} rejoint (${room.playerCount}/4)`)
})
// ─── Démarrer la partie ──────────────────────────────────────────────────────
socket.on('start-game', ({ roomId }) => {
const room = rooms.get(roomId)
if (!room || room.playerCount < 2) return
@@ -42,18 +44,21 @@ io.on('connection', (socket) => {
room.startGame()
io.to(roomId).emit('game-start')
// Envoyer l'état initial à chaque joueur avec sa main
room.players.forEach((player) => {
// Envoyer la main personnalisée à chaque joueur
room.players.forEach(player => {
io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId))
})
console.log(`[${roomId}] Partie démarrée`)
})
// ─── Demander l'état courant ─────────────────────────────────────────────────
socket.on('request-state', ({ roomId }) => {
const room = rooms.get(roomId)
if (!room) return
socket.emit('game-state', room.getStateFor(socket.id))
})
// ─── Jouer une carte ─────────────────────────────────────────────────────────
socket.on('play-card', ({ roomId, cardId }) => {
const room = rooms.get(roomId)
if (!room) return
@@ -64,40 +69,52 @@ io.on('connection', (socket) => {
return
}
// Mise à jour de phase pour tous
// Informer tous que quelqu'un a joué (sans révéler la carte)
io.to(roomId).emit('game-state', {
players: room.getPlayersInfo(),
players: room.getPlayersInfo(),
currentTurn: room.currentTurn,
maxTurns: room.maxTurns,
phase: room.phase
maxTurns: room.maxTurns,
phase: room.phase
})
// Tous les joueurs ont joué → résolution
// Tous les joueurs ont joué → résolution automatique
if (room.allPlayersPlayed()) {
const roundResult = room.resolveRound()
io.to(roomId).emit('game-state', {
players: room.getPlayersInfo(),
currentTurn: room.currentTurn,
maxTurns: room.maxTurns,
phase: 'resolve',
playedCards: roundResult.playedCards
console.log(`[${roomId}] Tour ${room.currentTurn} résolu`)
// Révéler les cartes jouées et les résultats
io.to(roomId).emit('round-result', {
playedCards: roundResult.playedCards,
attrResults: roundResult.attrResults,
message: roundResult.message
})
io.to(roomId).emit('round-result', { message: roundResult.message })
// Mise à jour scores
io.to(roomId).emit('game-state', {
players: room.getPlayersInfo(),
currentTurn: room.currentTurn,
maxTurns: room.maxTurns,
phase: 'resolve'
})
// 3 secondes d'affichage puis tour suivant / fin
setTimeout(() => {
room.nextTurn()
if (room.isGameOver()) {
io.to(roomId).emit('game-over', room.getPlayersInfo())
console.log(`[${roomId}] Partie terminée`)
} else {
room.nextTurn()
room.players.forEach((player) => {
// Distribuer la nouvelle main à chaque joueur
room.players.forEach(player => {
io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId))
})
}
}, 3000)
}, 3500)
}
})
// ─── Déconnexion ─────────────────────────────────────────────────────────────
socket.on('disconnect', () => {
const roomId = socket.data.roomId
if (roomId) {
@@ -105,13 +122,14 @@ io.on('connection', (socket) => {
if (room) {
room.removePlayer(socket.id)
io.to(roomId).emit('room-update', { players: room.getPlayersInfo() })
if (room.playerCount === 0) rooms.delete(roomId)
}
}
console.log(`[-] Déconnexion: ${socket.id}`)
console.log(`[-] ${socket.id}`)
})
})
const PORT = process.env.PORT || 3000
server.listen(PORT, () => {
console.log(`Serveur Regnum démarré sur le port ${PORT}`)
console.log(`Regnum server → http://localhost:${PORT}`)
})