This commit is contained in:
AfonsoCMSousa
2026-01-02 00:39:39 +00:00
parent bcdf542606
commit e84dd993af
11 changed files with 539 additions and 37 deletions
@@ -0,0 +1,304 @@
<template>
<div class="relative min-h-screen overflow-hidden">
<!-- Surrender Button -->
<div v-if="isGameRunning && !gameOver" class="absolute top-4 right-4 z-50">
<button
@click="handleSurrender"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2"
>
<span>🏳</span> Surrender
</button>
</div>
<!-- Connection Status -->
<div class="absolute top-4 left-4 z-50 flex items-center gap-2 bg-gray-800 px-4 py-2 rounded-lg border border-gray-700">
<div :class="['w-3 h-3 rounded-full', wsStore.connected ? 'bg-green-500' : 'bg-red-500']"></div>
<span class="text-white text-sm">{{ wsStore.connected ? 'Connected' : 'Disconnected' }}</span>
</div>
<!-- Timer -->
<div v-if="isMyTurn && isGameRunning && !gameOver" class="absolute top-4 left-1/2 -translate-x-1/2 z-50">
<div class="bg-gray-800 px-6 py-3 rounded-lg border-2" :class="timeLeft <= 5 ? 'border-red-500 animate-pulse' : 'border-gray-700'">
<div class="flex items-center gap-3">
<svg class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
<polyline points="12 6 12 12 16 14" stroke-width="2"/>
</svg>
<span :class="['text-3xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">
{{ timeLeft }}s
</span>
</div>
</div>
</div>
<!-- Game Board -->
<div v-if="isGameRunning">
<GameBoard
:trump-card="trumpCard"
:cards-remaining="deckCount"
:player-hand="playerHand"
:opponent-hand="opponentHand"
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:current-trick="currentTrick"
:is-my-turn="isMyTurn"
@play-card="handlePlayCard"
/>
</div>
<!-- Loading State -->
<div v-else-if="!gameOver" class="flex h-screen items-center justify-center bg-green-900 text-white">
<div class="text-center">
<div class="animate-spin h-12 w-12 border-4 border-white border-t-transparent rounded-full mx-auto mb-4"></div>
<div class="text-xl">Connecting to game...</div>
</div>
</div>
<!-- Trick Result Modal -->
<div v-if="showTrickResult" class="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50">
<div class="bg-gray-800 border-4 border-yellow-500 p-8 max-w-md rounded-lg">
<h3 class="text-3xl font-bold mb-4 text-center text-white">
{{ trickWinnerId === authStore.currentUser?.id ? '🎉 You won the trick!' : '😞 Opponent won the trick' }}
</h3>
<div class="text-center mb-4">
<div class="text-5xl font-bold text-yellow-500">+{{ trickPoints }} points</div>
</div>
</div>
</div>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOver"
:winner="gameResult"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="{ playerTricks: 0, opponentTricks: 0 }"
:is-logging-out="false"
@play-again="handlePlayAgain"
@close="handleCloseModal"
/>
<!-- Error Notification -->
<div v-if="wsStore.lastError" class="fixed bottom-4 right-4 bg-red-600 border-2 border-red-500 p-4 max-w-sm z-50 rounded-lg">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 flex-shrink-0 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="2"/>
<line x1="12" y1="8" x2="12" y2="12" stroke-width="2"/>
<line x1="12" y1="16" x2="12.01" y2="16" stroke-width="2"/>
</svg>
<div class="text-white">
<div class="font-semibold">Error</div>
<div class="text-sm">{{ wsStore.lastError }}</div>
</div>
<button @click="wsStore.lastError = null" class="ml-auto text-white">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<line x1="18" y1="6" x2="6" y2="18" stroke-width="2"/>
<line x1="6" y1="6" x2="18" y2="18" stroke-width="2"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, onBeforeMount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useSocketStore } from '@/stores/socket'
import { useAuthStore } from '@/stores/auth'
import { toast } from 'vue-sonner'
import GameBoard from '@/components/game/GameBoard.vue'
import GameOver from '@/components/game/GameOver.vue'
const route = useRoute()
const router = useRouter()
const wsStore = useWebSocketStore()
const authStore = useAuthStore()
const gameId = ref(route.params.id)
const gameType = ref(route.query.type || '9')
const timeLeft = ref(20)
const timerInterval = ref(null)
const isCardAnimating = ref(false)
const showTrickResult = ref(false)
const trickWinnerId = ref(null)
const trickPoints = ref(0)
const gameOver = ref(false)
const gameResult = ref(null)
const isGameRunning = ref(false)
// Computed properties from game state
const trumpCard = computed(() => wsStore.gameState?.trumpCard || null)
const deckCount = computed(() => wsStore.gameState?.deckCount || 0)
const playerHand = computed(() => wsStore.gameState?.hand || [])
const opponentHand = computed(() => {
const count = wsStore.gameState?.opponent?.cardCount || 0
return Array(count).fill({ id: 'back', suit: 'back', rank: 0 })
})
const playerScore = computed(() => wsStore.gameState?.points || 0)
const opponentScore = computed(() => wsStore.gameState?.opponent?.points || 0)
const currentTurn = computed(() => wsStore.gameState?.currentTurn)
const currentTrick = computed(() => ({
playerCard: wsStore.gameState?.table?.playerCard || null,
opponentCard: wsStore.gameState?.table?.opponentCard || null
}))
const isMyTurn = computed(() => {
return currentTurn.value == authStore.currentUser?.id
})
const startTimer = () => {
stopTimer()
timeLeft.value = 20
timerInterval.value = setInterval(() => {
timeLeft.value--
if (timeLeft.value <= 0) {
stopTimer()
handleTimeout()
}
}, 1000)
}
const stopTimer = () => {
if (timerInterval.value) {
clearInterval(timerInterval.value)
timerInterval.value = null
}
}
const handleTimeout = () => {
if (playerHand.value.length > 0) {
const randomCard = playerHand.value[0]
handlePlayCard(randomCard)
toast.warning('Time\'s up!', {
description: 'Auto-played first card'
})
}
}
const handlePlayCard = (card) => {
if (!isMyTurn.value || isCardAnimating.value || gameOver.value) return
isCardAnimating.value = true
wsStore.playCard(gameId.value, card.id)
stopTimer()
setTimeout(() => {
isCardAnimating.value = false
}, 500)
}
const handleSurrender = () => {
const confirmSurrender = window.confirm(
'Are you sure you want to surrender? Your opponent will be awarded the win.'
)
if (!confirmSurrender) return
toast.error('Game Surrendered', {
description: 'You forfeited the match.'
})
wsStore.disconnect()
router.push({ name: 'home' })
}
const handlePlayAgain = () => {
wsStore.disconnect()
router.push({ name: 'home' })
}
const handleCloseModal = () => {
wsStore.disconnect()
router.push({ name: 'home' })
}
const handleBrowserUnload = (event) => {
if (isGameRunning.value && !gameOver.value) {
event.preventDefault()
event.returnValue = ''
}
}
// Watch for turn changes
watch(() => currentTurn.value, (newTurn, oldTurn) => {
if (newTurn == authStore.currentUser?.id && newTurn !== oldTurn) {
startTimer()
} else {
stopTimer()
}
})
// Watch for game state to mark game as running
watch(() => wsStore.gameState, (state) => {
if (state && !isGameRunning.value) {
isGameRunning.value = true
}
})
onBeforeMount(() => {
gameOver.value = false
})
onMounted(() => {
wsStore.connect()
setTimeout(() => {
wsStore.joinGame(gameId.value)
}, 500)
wsStore.onTrickEnd((result) => {
trickWinnerId.value = result.winnerId
trickPoints.value = result.points
showTrickResult.value = true
setTimeout(() => {
showTrickResult.value = false
}, 3000)
})
wsStore.onGameOver((result) => {
stopTimer()
gameOver.value = true
if (result.isDraw) {
gameResult.value = 'draw'
} else if (result.winnerId === authStore.currentUser?.id) {
gameResult.value = 'player'
} else {
gameResult.value = 'opponent'
}
})
window.addEventListener('beforeunload', handleBrowserUnload)
})
onUnmounted(() => {
stopTimer()
wsStore.offTrickEnd()
wsStore.offGameOver()
wsStore.disconnect()
window.removeEventListener('beforeunload', handleBrowserUnload)
})
onBeforeRouteLeave((to, from, next) => {
if (!isGameRunning.value || gameOver.value) {
next()
return
}
const confirmExit = window.confirm(
'⚠️ Game in Progress!\n\nIf you leave now, you will forfeit the match.\n\nAre you sure you want to leave?'
)
if (confirmExit) {
wsStore.disconnect()
next()
} else {
next(false)
}
})
</script>
+50 -3
View File
@@ -12,6 +12,7 @@ import {
import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import axios from 'axios'
const API_BASE_URL = inject('apiBaseURL')
const apiStore = useAPIStore()
@@ -159,6 +160,52 @@ onMounted(async () => {
await nextTick();
setupGamesObserver();
})
const hostGame = async () => {
showHostModal.value = true
try {
const response = await axios.post(`${API_BASE_URL}/games/host`, {
type: selectedMode.value
})
const gameId = response.data.id
console.log('Game hosted:', gameId)
router.push({
name: 'multiplayer-game',
params: { id: gameId },
query: { type: selectedMode.value }
})
} catch (error) {
console.error('Failed to host game:', error)
console.error('Error details:', error.response?.data)
showHostModal.value = false
alert('Failed to host game: ' + (error.response?.data?.message || error.message))
}
}
const joinGame = async (game) => {
selectedGame.value = game
try {
await axios.post(`${API_BASE_URL}/games/${game.id}/join`)
console.log('Joined game:', game.id)
router.push({
name: 'multiplayer-game',
params: { id: game.id },
query: { type: selectedMode.value }
})
} catch (error) {
console.error('Failed to join game:', error)
console.error('Error details:', error.response?.data)
alert('Failed to join game: ' + (error.response?.data?.message || error.message))
}
}
</script>
<template>
@@ -212,7 +259,7 @@ onMounted(async () => {
<Card class="w-full max-w-2xl">
<CardHeader>
<CardTitle class="text-3xl font-bold text-center">
MultiPlayer
Multi Player
</CardTitle>
<CardDescription class="text-center">
Go one on one with another player!
@@ -226,7 +273,7 @@ onMounted(async () => {
</div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<div v-for="(game, index) in openGames" :key="game.id" @click="JoinGameModal(game)"
<div v-for="(game, index) in openGames" :key="game.id" @click="joinGame(game)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3">
@@ -249,7 +296,7 @@ onMounted(async () => {
</div>
</div>
<div class="flex justify-center">
<Button @click="hostGameModal()" size="lg" variant="secondary"
<Button @click="hostGame()" size="lg" variant="secondary"
class="hover:bg-purple-500 hover:text-slate-200">
Host Game
</Button>