commit 1db221e46879be542cf9b8050b1efad64219ba66 Author: kevin.lhereec Date: Mon Aug 17 11:34:55 2026 +0200 feat: initial Regnum project structure (Vue3 + Phaser3 + Socket.io) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa0926a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..265ff47 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Regnum + +Jeu de cartes en ligne multijoueur (2-4 joueurs). Collecte des points en jouant des cartes de royaume simultanément pendant X tours. + +## Stack + +- **Frontend** : Vue 3 + Phaser 3 + Pinia +- **Backend** : Node.js + Express + Socket.io +- **Temps réel** : WebSockets + +## Structure + +``` +regnum/ +├── client/ # Vue 3 + Phaser 3 +│ └── src/ +│ ├── views/ # HomeView, LobbyView, GameView +│ ├── game/ # Scènes Phaser (Boot, Game, UI) + objets +│ ├── stores/ # Pinia store +│ └── services/# Socket.io client +└── server/ # Node.js + └── src/ + ├── game/ # Room, RoomManager, cards + └── index.js # Serveur Express + Socket.io +``` + +## Lancement + +### Serveur +```bash +cd server +npm install +npm run dev +``` + +### Client +```bash +cd client +npm install +npm run dev +``` + +Ouvrir `http://localhost:5173` + +## Règles + +- 2 à 4 joueurs par partie +- Chaque tour, chaque joueur choisit **simultanément** une carte +- Les cartes sont révélées et résolues en même temps +- **Types de cartes** : + - **Royaume** : points fixes + - **Armée** : vole des points à un adversaire + - **Commerce** : bonus si associé à du Royaume + - **Magie** : effets spéciaux (protection, doublement, pioche) +- La partie dure 10 tours, le plus de points gagne diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..8afb21d --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + Regnum + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..cc63643 --- /dev/null +++ b/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "regnum-client", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "phaser": "^3.80.1", + "socket.io-client": "^4.7.5", + "vue": "^3.4.0", + "vue-router": "^4.3.0", + "pinia": "^2.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.2.0" + } +} diff --git a/client/src/App.vue b/client/src/App.vue new file mode 100644 index 0000000..5ca1aa7 --- /dev/null +++ b/client/src/App.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/client/src/game/GameFactory.js b/client/src/game/GameFactory.js new file mode 100644 index 0000000..bec4ffe --- /dev/null +++ b/client/src/game/GameFactory.js @@ -0,0 +1,28 @@ +import Phaser from 'phaser' +import { BootScene } from './scenes/BootScene' +import { GameScene } from './scenes/GameScene' +import { UIScene } from './scenes/UIScene' + +export function createGame(containerId, socket, store, roomId) { + const config = { + type: Phaser.AUTO, + parent: containerId, + width: window.innerWidth, + height: window.innerHeight - 60, + backgroundColor: '#0d0500', + scene: [BootScene, GameScene, UIScene], + scale: { + mode: Phaser.Scale.RESIZE, + autoCenter: Phaser.Scale.CENTER_BOTH + } + } + + const game = new Phaser.Game(config) + + // Passer socket, store et roomId aux scènes via le registry + game.registry.set('socket', socket) + game.registry.set('store', store) + game.registry.set('roomId', roomId) + + return game +} diff --git a/client/src/game/objects/CardObject.js b/client/src/game/objects/CardObject.js new file mode 100644 index 0000000..94d922a --- /dev/null +++ b/client/src/game/objects/CardObject.js @@ -0,0 +1,106 @@ +import Phaser from 'phaser' + +export class CardObject extends Phaser.GameObjects.Container { + constructor(scene, x, y, cardData) { + super(scene, x, y) + this.cardData = cardData + this._selected = false + + this.build() + scene.add.existing(this) + } + + build() { + // Fond de carte + this.bg = this.scene.add.image(0, 0, 'card-front') + this.add(this.bg) + + // Nom de la carte + this.nameText = this.scene.add.text(0, -60, this.cardData.name, { + fontSize: '12px', + color: '#f5e6c8', + fontFamily: 'Georgia', + wordWrap: { width: 100 }, + align: 'center' + }).setOrigin(0.5) + this.add(this.nameText) + + // 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) + + // 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) + this.setInteractive({ cursor: 'pointer' }) + + 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' + }) + } + } + + onOut() { + if (!this._selected) { + this.scene.tweens.add({ + targets: this, + y: this.y + 15, + duration: 150, + ease: 'Power2' + }) + } + } + + onDown() { + this._selected = !this._selected + if (this._selected) { + 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' + }) + } + + deselect() { + this._selected = false + this.bg.clearTint() + this.scene.tweens.add({ + targets: this, + y: this.y + 20, + duration: 150, + ease: 'Power2' + }) + } +} diff --git a/client/src/game/scenes/BootScene.js b/client/src/game/scenes/BootScene.js new file mode 100644 index 0000000..8509bdd --- /dev/null +++ b/client/src/game/scenes/BootScene.js @@ -0,0 +1,32 @@ +import Phaser from 'phaser' + +export class BootScene extends Phaser.Scene { + constructor() { + super('BootScene') + } + + preload() { + // Créer des textures procédurales pour les cartes + const graphics = this.add.graphics() + + // Fond de carte + graphics.fillStyle(0x2a1500) + graphics.fillRoundedRect(0, 0, 120, 180, 10) + graphics.lineStyle(2, 0xd4a732) + graphics.strokeRoundedRect(0, 0, 120, 180, 10) + graphics.generateTexture('card-back', 120, 180) + graphics.clear() + + // Carte face + graphics.fillStyle(0x3a2010) + graphics.fillRoundedRect(0, 0, 120, 180, 10) + graphics.lineStyle(2, 0xd4a732) + graphics.strokeRoundedRect(0, 0, 120, 180, 10) + graphics.generateTexture('card-front', 120, 180) + graphics.destroy() + } + + create() { + this.scene.start('GameScene') + } +} diff --git a/client/src/game/scenes/GameScene.js b/client/src/game/scenes/GameScene.js new file mode 100644 index 0000000..52565a2 --- /dev/null +++ b/client/src/game/scenes/GameScene.js @@ -0,0 +1,165 @@ +import Phaser from 'phaser' +import { CardObject } from '../objects/CardObject' + +export class GameScene extends Phaser.Scene { + constructor() { + super('GameScene') + this.hand = [] + this.selectedCard = null + this.playedCards = [] + } + + create() { + 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() + + // Demander l'état initial + this.socket.emit('request-state', { roomId: this.roomId }) + } + + createTable() { + 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) + + // Label tour + this.turnText = this.add.text(width / 2, 30, 'Tour 1/10', { + fontSize: '18px', + color: '#d4a732', + fontFamily: 'Georgia' + }).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) + + this.add.text(x, zoneY + 100, `J${i + 1}`, { + fontSize: '12px', + color: '#a08060', + fontFamily: 'Georgia' + }).setOrigin(0.5) + } + } + + createHand() { + const { width, height } = this.scale + const handY = height - 110 + + // 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) + }) + } + + createPlayButton() { + const { width, height } = this.scale + + this.playBtn = this.add.text(width / 2, height - 30, '[ JOUER LA CARTE ]', { + fontSize: '16px', + color: '#1a0a00', + backgroundColor: '#d4a732', + padding: { x: 20, y: 8 }, + fontFamily: 'Georgia' + }) + .setOrigin(0.5) + .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) }) + } + + onCardSelected(card) { + // Désélectionner la précédente + if (this.selectedCard && this.selectedCard !== card) { + this.selectedCard.deselect() + } + this.selectedCard = card + this.store.selectCard(card.cardData) + this.playBtn.setAlpha(1) + } + + playSelectedCard() { + if (!this.selectedCard || this.store.phase !== 'selecting') return + + this.socket.emit('play-card', { + roomId: this.roomId, + cardId: this.selectedCard.cardData.id + }) + + this.selectedCard.setAlpha(0.5) + this.selectedCard = null + this.store.clearSelection() + this.playBtn.setAlpha(0.4) + } + + setupSocketListeners() { + this.socket.on('game-state', (state) => { + this.store.updateState(state) + this.turnText.setText(`Tour ${state.currentTurn}/${state.maxTurns}`) + + if (state.phase === 'selecting') { + this.createHand() + } + + if (state.phase === 'resolve') { + this.showPlayedCards(state.playedCards) + } + }) + + this.socket.on('round-result', (result) => { + this.showRoundResult(result) + }) + } + + 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) + }) + } + + 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()) + } + + update() {} +} diff --git a/client/src/game/scenes/UIScene.js b/client/src/game/scenes/UIScene.js new file mode 100644 index 0000000..1214d00 --- /dev/null +++ b/client/src/game/scenes/UIScene.js @@ -0,0 +1,52 @@ +import Phaser from 'phaser' + +// Scène superposée pour les effets UI (notifications, fin de partie) +export class UIScene extends Phaser.Scene { + constructor() { + super({ key: 'UIScene', active: false }) + } + + create() { + this.socket = this.registry.get('socket') + + this.socket.on('game-over', (results) => { + this.showEndScreen(results) + }) + } + + showEndScreen(results) { + const { width, height } = this.scale + + // Fond semi-transparent + this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.85) + + this.add.text(width / 2, height / 2 - 150, 'FIN DE PARTIE', { + fontSize: '40px', + color: '#d4a732', + fontFamily: 'Georgia' + }).setOrigin(0.5) + + const sorted = [...results].sort((a, b) => b.score - a.score) + sorted.forEach((p, i) => { + const y = height / 2 - 80 + i * 50 + const medal = i === 0 ? '👑 ' : `${i + 1}. ` + this.add.text(width / 2, y, `${medal}${p.name} — ${p.score} pts`, { + fontSize: '22px', + color: i === 0 ? '#d4a732' : '#f5e6c8', + fontFamily: 'Georgia' + }).setOrigin(0.5) + }) + + const btn = this.add.text(width / 2, height / 2 + 150, '[ RETOUR AU MENU ]', { + fontSize: '18px', + color: '#1a0a00', + backgroundColor: '#d4a732', + padding: { x: 20, y: 10 }, + fontFamily: 'Georgia' + }).setOrigin(0.5).setInteractive({ cursor: 'pointer' }) + + btn.on('pointerdown', () => { + window.location.href = '/' + }) + } +} diff --git a/client/src/main.js b/client/src/main.js new file mode 100644 index 0000000..7b5cd15 --- /dev/null +++ b/client/src/main.js @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/client/src/router.js b/client/src/router.js new file mode 100644 index 0000000..eadef4d --- /dev/null +++ b/client/src/router.js @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from './views/HomeView.vue' +import LobbyView from './views/LobbyView.vue' +import GameView from './views/GameView.vue' + +const routes = [ + { path: '/', component: HomeView }, + { path: '/lobby/:roomId', component: LobbyView }, + { path: '/game/:roomId', component: GameView } +] + +export default createRouter({ + history: createWebHistory(), + routes +}) diff --git a/client/src/services/socket.js b/client/src/services/socket.js new file mode 100644 index 0000000..1f3e55e --- /dev/null +++ b/client/src/services/socket.js @@ -0,0 +1,12 @@ +import { io } from 'socket.io-client' + +let socket = null + +export function useSocket() { + if (!socket) { + socket = io(import.meta.env.VITE_SERVER_URL || 'http://localhost:3000', { + autoConnect: true + }) + } + return socket +} diff --git a/client/src/stores/game.js b/client/src/stores/game.js new file mode 100644 index 0000000..2e56cd7 --- /dev/null +++ b/client/src/stores/game.js @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useGameStore = defineStore('game', () => { + const playerName = ref('') + const playerId = ref('') + const players = ref([]) + const currentTurn = ref(0) + const maxTurns = ref(10) + const phase = ref('idle') // idle | selecting | waiting | resolve | end + const hand = ref([]) + const selectedCard = ref(null) + + function setPlayer(name) { + playerName.value = name + } + + function updateState(state) { + players.value = state.players + currentTurn.value = state.currentTurn + maxTurns.value = state.maxTurns + phase.value = state.phase + hand.value = state.hand || hand.value + } + + function selectCard(card) { + selectedCard.value = card + } + + function clearSelection() { + selectedCard.value = null + } + + return { + playerName, playerId, + players, currentTurn, maxTurns, + phase, hand, selectedCard, + setPlayer, updateState, selectCard, clearSelection + } +}) diff --git a/client/src/views/GameView.vue b/client/src/views/GameView.vue new file mode 100644 index 0000000..1b2dbc5 --- /dev/null +++ b/client/src/views/GameView.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/client/src/views/HomeView.vue b/client/src/views/HomeView.vue new file mode 100644 index 0000000..7cf93aa --- /dev/null +++ b/client/src/views/HomeView.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/client/src/views/LobbyView.vue b/client/src/views/LobbyView.vue new file mode 100644 index 0000000..e95985a --- /dev/null +++ b/client/src/views/LobbyView.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..59a1c68 --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + port: 5173, + proxy: { + '/socket.io': { + target: 'http://localhost:3000', + ws: true + } + } + } +}) diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..77509af --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "regnum-server", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "dependencies": { + "express": "^4.19.0", + "socket.io": "^4.7.5", + "cors": "^2.8.5" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/server/src/game/Room.js b/server/src/game/Room.js new file mode 100644 index 0000000..d442731 --- /dev/null +++ b/server/src/game/Room.js @@ -0,0 +1,185 @@ +const { dealHands, createDeck } = require('./cards') + +const MAX_PLAYERS = 4 +const MAX_TURNS = 10 +const HAND_SIZE = 5 + +class Room { + constructor(id) { + this.id = id + this.players = [] // { socketId, name, score, hand, protected } + this.phase = 'lobby' // lobby | selecting | waiting | resolve | end + this.currentTurn = 0 + this.maxTurns = MAX_TURNS + this.pendingPlays = new Map() // socketId -> card + this.deck = [] + } + + get playerCount() { return this.players.length } + + addPlayer(socketId, name) { + if (this.players.length >= MAX_PLAYERS) return false + if (this.players.find(p => p.socketId === socketId)) return true + + this.players.push({ + socketId, + name, + score: 0, + hand: [], + protected: false + }) + return true + } + + removePlayer(socketId) { + this.players = this.players.filter(p => p.socketId !== socketId) + } + + startGame() { + const { hands, remaining } = dealHands(this.players.length, HAND_SIZE) + this.deck = remaining + this.players.forEach((p, i) => { p.hand = hands[i] }) + this.currentTurn = 1 + this.phase = 'selecting' + this.pendingPlays.clear() + } + + 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' } + + 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' } + + const card = player.hand.splice(cardIndex, 1)[0] + this.pendingPlays.set(socketId, { player, card }) + + this.phase = 'waiting' + return { ok: true } + } + + allPlayersPlayed() { + return this.pendingPlays.size === this.players.length + } + + 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 }) + + // 1. Appliquer les effets de protection (Enchanteur) + plays.forEach(({ player, card }) => { + if (card.effect === 'protect') { + player.protected = true + } + }) + + // 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 + } + } + }) + + const message = messages.length > 0 ? messages.join(' | ') : 'Tour résolu !' + + this.pendingPlays.clear() + this.phase = 'resolve' + + return { playedCards, message } + } + + 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()) + } + }) + } + + isGameOver() { + return this.currentTurn >= this.maxTurns + } + + getPlayersInfo() { + return this.players.map(p => ({ + name: p.name, + score: p.score, + handSize: p.hand.length + })) + } + + getStateFor(socketId) { + const player = this.players.find(p => p.socketId === socketId) + return { + players: this.getPlayersInfo(), + currentTurn: this.currentTurn, + maxTurns: this.maxTurns, + phase: this.phase, + hand: player ? player.hand : [] + } + } +} + +module.exports = { Room } diff --git a/server/src/game/RoomManager.js b/server/src/game/RoomManager.js new file mode 100644 index 0000000..01ca70c --- /dev/null +++ b/server/src/game/RoomManager.js @@ -0,0 +1,24 @@ +const { Room } = require('./Room') + +class RoomManager { + constructor() { + this.rooms = new Map() + } + + getOrCreate(roomId) { + if (!this.rooms.has(roomId)) { + this.rooms.set(roomId, new Room(roomId)) + } + return this.rooms.get(roomId) + } + + get(roomId) { + return this.rooms.get(roomId) || null + } + + delete(roomId) { + this.rooms.delete(roomId) + } +} + +module.exports = { RoomManager } diff --git a/server/src/game/cards.js b/server/src/game/cards.js new file mode 100644 index 0000000..424e497 --- /dev/null +++ b/server/src/game/cards.js @@ -0,0 +1,51 @@ +// Deck de base de Regnum +// Types: Royaume (points fixes), Armée (attaque), Commerce (multiplicateur), Magie (effets spéciaux) + +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' }, +] + +function shuffle(arr) { + const a = [...arr] + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]] + } + return a +} + +function createDeck() { + return shuffle([...BASE_DECK, ...BASE_DECK]) // 2 exemplaires +} + +function dealHands(playerCount, handSize = 5) { + const deck = createDeck() + const hands = [] + for (let i = 0; i < playerCount; i++) { + hands.push(deck.splice(0, handSize)) + } + return { hands, remaining: deck } +} + +module.exports = { createDeck, dealHands, BASE_DECK } diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..96707c9 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,117 @@ +const express = require('express') +const http = require('http') +const { Server } = require('socket.io') +const cors = require('cors') +const { RoomManager } = require('./game/RoomManager') + +const app = express() +app.use(cors()) +app.use(express.json()) + +const server = http.createServer(app) +const io = new Server(server, { + cors: { origin: '*', methods: ['GET', 'POST'] } +}) + +const rooms = new RoomManager() + +io.on('connection', (socket) => { + console.log(`[+] Connexion: ${socket.id}`) + + socket.on('join-room', ({ roomId, playerName }) => { + const room = rooms.getOrCreate(roomId) + const joined = room.addPlayer(socket.id, playerName) + + if (!joined) { + socket.emit('error', { message: 'Salle pleine (4 joueurs max)' }) + return + } + + socket.join(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)`) + }) + + socket.on('start-game', ({ roomId }) => { + const room = rooms.get(roomId) + if (!room || room.playerCount < 2) return + + room.startGame() + io.to(roomId).emit('game-start') + + // Envoyer l'état initial à chaque joueur avec sa main + room.players.forEach((player) => { + io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId)) + }) + }) + + socket.on('request-state', ({ roomId }) => { + const room = rooms.get(roomId) + if (!room) return + socket.emit('game-state', room.getStateFor(socket.id)) + }) + + socket.on('play-card', ({ roomId, cardId }) => { + const room = rooms.get(roomId) + if (!room) return + + const result = room.playCard(socket.id, cardId) + if (!result.ok) { + socket.emit('error', { message: result.error }) + return + } + + // Mise à jour de phase pour tous + io.to(roomId).emit('game-state', { + players: room.getPlayersInfo(), + currentTurn: room.currentTurn, + maxTurns: room.maxTurns, + phase: room.phase + }) + + // Tous les joueurs ont joué → résolution + 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 + }) + + io.to(roomId).emit('round-result', { message: roundResult.message }) + + setTimeout(() => { + if (room.isGameOver()) { + io.to(roomId).emit('game-over', room.getPlayersInfo()) + } else { + room.nextTurn() + room.players.forEach((player) => { + io.to(player.socketId).emit('game-state', room.getStateFor(player.socketId)) + }) + } + }, 3000) + } + }) + + socket.on('disconnect', () => { + const roomId = socket.data.roomId + if (roomId) { + const room = rooms.get(roomId) + if (room) { + room.removePlayer(socket.id) + io.to(roomId).emit('room-update', { players: room.getPlayersInfo() }) + } + } + console.log(`[-] Déconnexion: ${socket.id}`) + }) +}) + +const PORT = process.env.PORT || 3000 +server.listen(PORT, () => { + console.log(`Serveur Regnum démarré sur le port ${PORT}`) +})