feat: initial Regnum project structure (Vue3 + Phaser3 + Socket.io)

This commit is contained in:
kevin.lhereec committed 2026-08-17 11:34:55 +02:00
commit 1db221e468
23 files changed
+1265

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
body {
background: #1a0a00;
color: #f5e6c8;
font-family: 'Georgia', serif;
}
</style>
+28
View File
@@ -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
}
+106
View File
@@ -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'
})
}
}
+32
View File
@@ -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')
}
}
+165
View File
@@ -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() {}
}
+52
View File
@@ -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 = '/'
})
}
}
+9
View File
@@ -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')
+15
View File
@@ -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
})
+12
View File
@@ -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
}
+40
View File
@@ -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
}
})
+83
View File
@@ -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>
+87
View File
@@ -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>
+111
View File
@@ -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>