feat: initial Regnum project structure (Vue3 + Phaser3 + Socket.io)
This commit is contained in:
commit
1db221e468
23 files changed
+1265
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regnum</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a0a00; color: #f5e6c8; font-family: 'Georgia', serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: #1a0a00;
|
||||
color: #f5e6c8;
|
||||
font-family: 'Georgia', serif;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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 = '/'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="game-wrapper">
|
||||
<!-- UI Vue: scores et infos -->
|
||||
<div class="hud">
|
||||
<div class="scores">
|
||||
<div v-for="(player, i) in gameStore.players" :key="i" class="score-card">
|
||||
<span class="pname">{{ player.name }}</span>
|
||||
<span class="pts">{{ player.score }} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="turn-info">
|
||||
Tour <strong>{{ gameStore.currentTurn }}</strong> / {{ gameStore.maxTurns }}
|
||||
</div>
|
||||
<div v-if="gameStore.phase === 'waiting'" class="phase-label">
|
||||
En attente des autres joueurs...
|
||||
</div>
|
||||
<div v-else-if="gameStore.phase === 'resolve'" class="phase-label">
|
||||
Résolution du tour...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvas Phaser -->
|
||||
<div id="phaser-container" ref="phaserContainer"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useSocket } from '@/services/socket'
|
||||
import { createGame } from '@/game/GameFactory'
|
||||
|
||||
const route = useRoute()
|
||||
const gameStore = useGameStore()
|
||||
const socket = useSocket()
|
||||
const phaserContainer = ref(null)
|
||||
let phaserGame = null
|
||||
|
||||
onMounted(() => {
|
||||
phaserGame = createGame('phaser-container', socket, gameStore, route.params.roomId)
|
||||
|
||||
socket.on('game-state', (state) => {
|
||||
gameStore.updateState(state)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('game-state')
|
||||
if (phaserGame) phaserGame.destroy(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.game-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #0d0500;
|
||||
}
|
||||
.hud {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #1a0a00;
|
||||
border-bottom: 1px solid #d4a73240;
|
||||
}
|
||||
.scores { display: flex; gap: 1rem; }
|
||||
.score-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.25rem 1rem;
|
||||
border: 1px solid #d4a73250;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pname { font-size: 0.75rem; color: #a08060; }
|
||||
.pts { font-size: 1.1rem; font-weight: bold; color: #d4a732; }
|
||||
.turn-info { margin-left: auto; color: #f5e6c8; }
|
||||
.phase-label { color: #d4a732; font-style: italic; }
|
||||
#phaser-container { flex: 1; }
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<h1 class="title">REGNUM</h1>
|
||||
<p class="subtitle">Le jeu de cartes des royaumes</p>
|
||||
|
||||
<div class="form">
|
||||
<input v-model="playerName" placeholder="Ton nom de joueur" maxlength="20" />
|
||||
<button @click="createRoom" :disabled="!playerName.trim()">Créer une partie</button>
|
||||
<div class="or">— ou —</div>
|
||||
<input v-model="roomCode" placeholder="Code de la salle" maxlength="6" />
|
||||
<button @click="joinRoom" :disabled="!playerName.trim() || !roomCode.trim()">Rejoindre</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useGameStore()
|
||||
const playerName = ref('')
|
||||
const roomCode = ref('')
|
||||
|
||||
function createRoom() {
|
||||
const id = Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||
store.setPlayer(playerName.value)
|
||||
router.push(`/lobby/${id}`)
|
||||
}
|
||||
|
||||
function joinRoom() {
|
||||
store.setPlayer(playerName.value)
|
||||
router.push(`/lobby/${roomCode.value.toUpperCase()}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
.title {
|
||||
font-size: 5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
color: #d4a732;
|
||||
text-shadow: 0 0 30px #d4a73280;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
color: #a08060;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 300px;
|
||||
}
|
||||
input {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #2a1500;
|
||||
border: 1px solid #d4a732;
|
||||
color: #f5e6c8;
|
||||
font-size: 1rem;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: #d4a732;
|
||||
color: #1a0a00;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #e8c04a; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.or { text-align: center; color: #a08060; }
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="lobby">
|
||||
<h2>Salle : <span class="code">{{ roomId }}</span></h2>
|
||||
<p class="hint">Partage ce code à tes adversaires (4 joueurs max)</p>
|
||||
|
||||
<div class="players">
|
||||
<div
|
||||
v-for="(player, i) in players"
|
||||
:key="i"
|
||||
class="player-slot"
|
||||
:class="{ filled: player }"
|
||||
>
|
||||
<span v-if="player">{{ player.name }}</span>
|
||||
<span v-else class="empty">En attente...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="start-btn"
|
||||
@click="startGame"
|
||||
:disabled="players.filter(Boolean).length < 2"
|
||||
>
|
||||
Lancer la partie
|
||||
</button>
|
||||
<p class="min-note">Minimum 2 joueurs pour démarrer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useSocket } from '@/services/socket'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useGameStore()
|
||||
const socket = useSocket()
|
||||
const roomId = route.params.roomId
|
||||
|
||||
const players = ref([null, null, null, null])
|
||||
|
||||
onMounted(() => {
|
||||
socket.emit('join-room', { roomId, playerName: store.playerName })
|
||||
|
||||
socket.on('room-update', (data) => {
|
||||
players.value = [...data.players, null, null, null, null].slice(0, 4)
|
||||
})
|
||||
|
||||
socket.on('game-start', () => {
|
||||
router.push(`/game/${roomId}`)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
socket.off('room-update')
|
||||
socket.off('game-start')
|
||||
})
|
||||
|
||||
function startGame() {
|
||||
socket.emit('start-game', { roomId })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lobby {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
h2 { font-size: 2rem; }
|
||||
.code { color: #d4a732; letter-spacing: 0.3rem; }
|
||||
.hint { color: #a08060; }
|
||||
.players {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
width: 400px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.player-slot {
|
||||
padding: 1rem;
|
||||
border: 2px dashed #4a3020;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.player-slot.filled {
|
||||
border-color: #d4a732;
|
||||
background: #2a1500;
|
||||
}
|
||||
.empty { color: #4a3020; font-style: italic; }
|
||||
.start-btn {
|
||||
padding: 1rem 3rem;
|
||||
background: #d4a732;
|
||||
color: #1a0a00;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.start-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.min-note { color: #a08060; font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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}`)
|
||||
})
|
||||
Reference in new issue
Block a user