Merge pull request 'SyntaxSquad/DADProject#14 feature/frontend-gameboard-singleplayer' (#75) from feature/frontend-gameboard-singleplayer into develop

Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/75
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
This commit was merged in pull request #75.
This commit is contained in:
Edd
2025-12-24 23:35:40 +00:00
64 changed files with 3141 additions and 29 deletions
+1
View File
@@ -80,6 +80,7 @@ frontend/.env.*.local
# Websockets: NodeJs (in websockets)
# -------------------------
/websockets/node_modules/
/websockets/.env
# -------------------------
# Other useful ignores
# -------------------------
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+9 -16
View File
@@ -3,8 +3,8 @@
<nav class="max-w-full p-5 flex flex-row justify-between align-middle">
<div class="align-middle text-xl">
<RouterLink to="/"> {{ pageTitle }} </RouterLink>
<span class="text-xs" v-if="authStore.currentUser">&nbsp;&nbsp;&nbsp;
({{ authStore.currentUser?.name }})
<span class="text-xs" v-if="authStore.currentUser"
>&nbsp;&nbsp;&nbsp; ({{ authStore.currentUser?.name }})
</span>
</div>
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
@@ -17,26 +17,22 @@
</template>
<script setup>
import { RouterLink, RouterView } from 'vue-router';
import { toast } from 'vue-sonner';
import { RouterLink, RouterView } from 'vue-router'
import { toast } from 'vue-sonner'
import 'vue-sonner/style.css'
import { ref, onMounted } from 'vue';
import { ref, onMounted } from 'vue'
import { Toaster } from '@/components/ui/sonner'
import NavBar from './components/layout/NavBar.vue';
import { useAuthStore } from './stores/auth';
import { useSocketStore } from './stores/socket';
import NavBar from './components/layout/NavBar.vue'
import { useAuthStore } from './stores/auth'
import { useSocketStore } from './stores/socket'
const authStore = useAuthStore()
const socketStore = useSocketStore()
const year = new Date().getFullYear()
const pageTitle = ref(`DAD ${year}/${String(year + 1).slice(-2)}`)
const logout = () => {
toast.promise(authStore.logout(), {
loading: 'Calling API',
success: () => {
@@ -44,15 +40,12 @@ const logout = () => {
},
error: (data) => `[API] Error - ${data?.response?.data?.message}`,
})
}
onMounted(async () => {
await authStore.restoreSession()
socketStore.handleConnection()
})
</script>
<style></style>`
<style></style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="relative flex items-center justify-center w-64 h-48">
<div
v-if="trumpCard"
class="absolute rotate-90 origin-center transition-all duration-700"
:class="[trumpReveal && 'scale-110']"
>
<div
class="ring-2 ring-amber-400/60 ring-offset-2 rounded-lg transition-all duration-700"
:class="[
trumpReveal &&
'ring-4 ring-amber-400 shadow-[0_0_30px_rgba(251,191,36,0.8)] animate-pulse',
]"
>
<GameCard :suit="trumpCard.suit" :rank="trumpCard.rank" />
</div>
<div
v-if="trumpReveal"
class="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-amber-500/90 text-white text-[10px] font-bold px-3 py-1 rounded-full whitespace-nowrap animate-pulse"
>
TRUMP
</div>
</div>
<div
v-if="!isEmpty && cardsRemaining > 0"
class="absolute -translate-x-8 -translate-y-4 transition-all duration-300"
:class="[cardsRemaining === 0 && 'opacity-0 scale-95', deckPulse && 'scale-105']"
>
<div class="relative">
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-20 translate-x-1 translate-y-1"
></div>
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-15 translate-x-2 translate-y-2"
></div>
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-10 translate-x-3 translate-y-3"
></div>
<div class="relative">
<GameCard suit="c" :rank="1" :face-down="true" />
<div
class="absolute -top-2 -right-2 bg-blue-600 text-white text-xs font-bold rounded-full w-8 h-8 flex items-center justify-center shadow-lg border-2 border-white transition-all duration-300"
>
<Transition
enter-active-class="transition-all duration-200"
enter-from-class="scale-150 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition-all duration-200"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-50 opacity-0"
mode="out-in"
>
<span :key="cardsRemaining">{{ cardsRemaining }}</span>
</Transition>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import GameCard from './GameCard.vue'
const props = defineProps({
trumpCard: {
type: Object,
default: null,
},
cardsRemaining: {
type: Number,
required: true,
},
isEmpty: {
type: Boolean,
required: true,
},
revealTrump: {
type: Boolean,
default: false,
},
})
const deckPulse = ref(false)
const trumpReveal = ref(false)
// Pulse animation when cards remaining changes
watch(
() => props.cardsRemaining,
() => {
deckPulse.value = true
setTimeout(() => {
deckPulse.value = false
}, 300)
},
)
// Trump reveal animation
watch(
() => props.revealTrump,
(newValue) => {
if (newValue) {
trumpReveal.value = true
setTimeout(() => {
trumpReveal.value = false
}, 3000) // Show for 3 seconds
}
},
)
</script>
@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<div
v-if="isVisible"
class="fixed pointer-events-none z-[9999]"
:style="{
left: currentPosition.x + 'px',
top: currentPosition.y + 'px',
transition: isAnimating ? `all ${duration}ms ease-out` : 'none',
}"
>
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import GameCard from './GameCard.vue'
const props = defineProps({
card: {
type: Object,
required: true,
},
startPosition: {
type: Object,
required: true,
validator: (value) => 'x' in value && 'y' in value,
},
endPosition: {
type: Object,
required: true,
validator: (value) => 'x' in value && 'y' in value,
},
duration: {
type: Number,
default: 500,
},
faceDown: {
type: Boolean,
default: true,
},
delay: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['complete'])
const isVisible = ref(false)
const isAnimating = ref(false)
const currentPosition = ref({ x: 0, y: 0 })
onMounted(() => {
// Start at the deck position
currentPosition.value = { ...props.startPosition }
isVisible.value = true
// Wait for delay, then start animation
setTimeout(() => {
// Force a reflow to ensure starting position is set
requestAnimationFrame(() => {
isAnimating.value = true
currentPosition.value = { ...props.endPosition }
// After animation completes, emit event and hide
setTimeout(() => {
emit('complete')
isVisible.value = false
}, props.duration)
})
}, props.delay)
})
</script>
@@ -0,0 +1,77 @@
<template>
<div
class="min-h-screen bg-linear-to-br from-green-700 via-green-800 to-green-900 grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8"
>
<div class="col-start-1 col-end-4 row-start-1 flex justify-start items-start">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
<PlayerHand
:cards="opponentHand"
:face-down="true"
:max-cards="maxCards"
/>
</div>
<div class="col-start-2 col-end-3 row-start-3 flex items-center justify-center">
<PlayArea
:player-card="currentTrick.playerCard"
:opponent-card="currentTrick.opponentCard"
:winner="currentTrick.winner"
/>
</div>
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
<DeckArea :trump-card="trumpCard" :cards-remaining="cardsRemaining" :is-empty="deckIsEmpty" />
</div>
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
<PlayerHand
:cards="playerHand"
:face-down="false"
:max-cards="maxCards"
@card-clicked="handleCardClick"
/>
</div>
</div>
</template>
<script setup>
import PlayerHand from './PlayerHand.vue'
import PlayArea from './PlayArea.vue'
import DeckArea from './DeckArea.vue'
import ScoreDisplay from './ScoreDisplay.vue'
const props = defineProps({
playerHand: { type: Array, default: () => [] },
opponentHand: { type: Array, default: () => [] },
currentTrick: {
type: Object,
default: () => ({ playerCard: null, opponentCard: null, winner: null }),
},
trumpCard: { type: Object, required: true },
cardsRemaining: { type: Number, default: 0 },
deckIsEmpty: { type: Boolean, default: false },
maxCards: { type: Number, default: 3 },
// REMOVIDO: playableCardIndices (já não precisamos dele)
playerScore: { type: Number, default: 0 },
opponentScore: { type: Number, default: 0 },
currentTurn: {
type: String,
default: 'player',
validator: (value) => ['player', 'opponent'].includes(value),
},
})
const emit = defineEmits(['play-card'])
const handleCardClick = (payload) => {
emit('play-card', payload.card)
}
</script>
+39
View File
@@ -0,0 +1,39 @@
<template>
<div
class="relative w-32 aspect-[2.5/3.5] cursor-pointer transition-all duration-300 ease-in-out hover:scale-105 hover:-translate-y-2 hover:shadow-2xl"
>
<img
:src="cardImage"
:alt="faceDown ? 'Card back' : `${suit} ${rank}`"
class="w-full h-full object-cover rounded-lg shadow-lg select-none"
draggable="false"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
suit: {
type: String,
required: true,
},
rank: {
type: Number,
required: true,
},
faceDown: {
type: Boolean,
default: false,
},
})
const cardImage = computed(() => {
if (props.faceDown) {
return '/cards/semFace.png'
} else {
return `/cards/${props.suit}${props.rank}.png`
}
})
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998] flex items-center justify-center p-4"
@click.self="handleClose"
>
<Transition
enter-active-class="transition-all duration-500 ease-out delay-100"
enter-from-class="opacity-0 scale-75 -translate-y-10"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
>
<div
v-if="isVisible"
class="bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 rounded-2xl shadow-2xl border-2 max-w-md w-full p-8 relative overflow-hidden"
:class="[
winner === 'player'
? 'border-emerald-500/50'
: winner === 'opponent'
? 'border-rose-500/50'
: 'border-gray-600/50',
]"
>
<!-- Confetti Effect (if player wins) -->
<div
v-if="winner === 'player' && showConfetti"
class="absolute inset-0 pointer-events-none"
>
<div
v-for="i in 30"
:key="i"
class="absolute w-2 h-2 animate-confetti"
:style="{
left: Math.random() * 100 + '%',
top: '-10px',
backgroundColor: ['#fbbf24', '#34d399', '#60a5fa', '#f472b6', '#a78bfa'][
Math.floor(Math.random() * 5)
],
animationDelay: Math.random() * 2 + 's',
animationDuration: 3 + Math.random() * 2 + 's',
}"
></div>
</div>
<!-- Winner Icon/Badge -->
<div class="flex justify-center mb-6">
<div
class="relative"
:class="[winner === 'player' ? 'animate-bounce' : 'animate-pulse']"
>
<div
v-if="winner === 'player'"
class="w-24 h-24 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/50"
>
<span class="text-5xl">🏆</span>
</div>
<div
v-else-if="winner === 'opponent'"
class="w-24 h-24 rounded-full bg-gradient-to-br from-rose-400 to-rose-600 flex items-center justify-center shadow-lg shadow-rose-500/50"
>
<span class="text-5xl">😔</span>
</div>
<div
v-else
class="w-24 h-24 rounded-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center shadow-lg"
>
<span class="text-5xl">🤝</span>
</div>
</div>
</div>
<!-- Title -->
<h2
class="text-4xl font-black text-center mb-2 bg-clip-text text-transparent bg-gradient-to-r"
:class="[
winner === 'player'
? 'from-emerald-300 to-emerald-500'
: winner === 'opponent'
? 'from-rose-300 to-rose-500'
: 'from-gray-300 to-gray-500',
]"
>
{{ title }}
</h2>
<!-- Subtitle -->
<p class="text-center text-gray-400 mb-8">{{ subtitle }}</p>
<!-- Scores -->
<div class="bg-black/30 rounded-xl p-6 mb-8 space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-300 font-medium">{{ playerName }}</span>
<span
class="text-3xl font-black tabular-nums transition-all duration-500"
:class="[
playerScore > opponentScore
? 'text-emerald-400 drop-shadow-[0_0_12px_rgba(52,211,153,0.6)]'
: 'text-white',
]"
>
{{ displayPlayerScore }}
</span>
</div>
<div class="h-px bg-gray-700"></div>
<div class="flex items-center justify-between">
<span class="text-gray-300 font-medium">{{ opponentName }}</span>
<span
class="text-3xl font-black tabular-nums transition-all duration-500"
:class="[
opponentScore > playerScore
? 'text-rose-400 drop-shadow-[0_0_12px_rgba(251,113,133,0.6)]'
: 'text-white',
]"
>
{{ displayOpponentScore }}
</span>
</div>
</div>
<!-- Stats (optional) -->
<div v-if="stats" class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-black/20 rounded-lg p-3 text-center">
<p class="text-gray-400 text-xs mb-1">Your Tricks</p>
<p class="text-2xl font-bold text-white">{{ stats.playerTricks }}</p>
</div>
<div class="bg-black/20 rounded-lg p-3 text-center">
<p class="text-gray-400 text-xs mb-1">Opponent Tricks</p>
<p class="text-2xl font-bold text-white">{{ stats.opponentTricks }}</p>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button
v-if="!isLoggingOut"
@click="$emit('play-again')"
class="flex-1 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-bold py-3 px-6 rounded-lg transition-all shadow-lg"
>
Play Again
</button>
<button
v-if="!hideClose"
@click="handleClose"
class="flex-1 font-bold py-3 px-6 rounded-lg transition-all"
:class="[
isLoggingOut
? 'bg-red-600 hover:bg-red-700 text-white w-full'
: 'bg-gray-700 hover:bg-gray-600 text-white',
]"
>
{{ isLoggingOut ? 'Confirm Logout' : 'Close' }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
isVisible: {
type: Boolean,
required: true,
},
winner: {
type: String,
required: true,
validator: (value) => ['player', 'opponent', 'draw'].includes(value),
},
playerScore: {
type: Number,
required: true,
},
opponentScore: {
type: Number,
required: true,
},
playerName: {
type: String,
default: 'You',
},
opponentName: {
type: String,
default: 'Bot',
},
stats: {
type: Object,
default: null,
},
hideClose: {
type: Boolean,
default: false,
},
isLoggingOut: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close', 'play-again'])
const showConfetti = ref(false)
const displayPlayerScore = ref(0)
const displayOpponentScore = ref(0)
// Computed title based on winner
const title = computed(() => {
if (props.winner === 'player') return 'Victory!'
if (props.winner === 'opponent') return 'Defeat'
return 'Draw!'
})
// Computed subtitle
const subtitle = computed(() => {
if (props.winner === 'player') return 'Congratulations! You won the game!'
if (props.winner === 'opponent') return 'Better luck next time!'
return 'The game ended in a draw'
})
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => {
const duration = 1000
const steps = 30
const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps
let current = fromValue
let step = 0
const interval = setInterval(() => {
step++
current += increment
if (step >= steps) {
current = toValue
clearInterval(interval)
}
callback(Math.round(current))
}, stepDuration)
}
// Watch for visibility and trigger animations
watch(
() => props.isVisible,
(newValue) => {
if (newValue) {
// Start confetti if player wins
if (props.winner === 'player') {
setTimeout(() => {
showConfetti.value = true
}, 300)
}
// Animate scores counting up
setTimeout(() => {
animateScore(0, props.playerScore, (value) => {
displayPlayerScore.value = value
})
animateScore(0, props.opponentScore, (value) => {
displayOpponentScore.value = value
})
}, 400)
} else {
showConfetti.value = false
displayPlayerScore.value = 0
displayOpponentScore.value = 0
}
},
{ immediate: true },
)
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti linear forwards;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="flex items-center justify-center h-64 relative">
<div class="relative w-96 h-full flex items-center justify-center">
<!-- Opponent Card Slot -->
<div
class="absolute top-4"
:class="[
winner === 'opponent' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
]"
:style="{ zIndex: opponentCard ? (firstPlayer === 'opponent' ? 1 : 2) : 0 }"
>
<div
v-if="!opponentCard"
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
>
<span class="text-gray-400 text-xs font-medium">Opponent</span>
</div>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 -translate-y-[200px] scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100 rotate-6"
leave-active-class="transition-all duration-500 ease-in"
:leave-from-class="`opacity-100 translate-y-0 scale-100 rotate-6`"
:leave-to-class="
winner === 'opponent'
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
"
mode="out-in"
>
<div
v-if="opponentCard"
:key="`${opponentCard.suit}-${opponentCard.rank}`"
class="rotate-6"
>
<GameCard :suit="opponentCard.suit" :rank="opponentCard.rank" />
</div>
</Transition>
</div>
<!-- Player Card Slot -->
<div
class="absolute bottom-4"
:class="[
winner === 'player' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
]"
:style="{ zIndex: playerCard ? (firstPlayer === 'player' ? 1 : 2) : 0 }"
>
<div
v-if="!playerCard"
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
>
<span class="text-gray-400 text-xs font-medium">You</span>
</div>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 translate-y-[200px] scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100 -rotate-6"
leave-active-class="transition-all duration-500 ease-in"
:leave-from-class="`opacity-100 translate-y-0 scale-100 -rotate-6`"
:leave-to-class="
winner === 'opponent'
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
"
mode="out-in"
>
<div v-if="playerCard" :key="`${playerCard.suit}-${playerCard.rank}`" class="-rotate-6">
<GameCard :suit="playerCard.suit" :rank="playerCard.rank" />
</div>
</Transition>
</div>
</div>
</div>
</template>
<script setup>
import GameCard from './GameCard.vue'
const props = defineProps({
playerCard: {
type: Object,
default: null,
},
opponentCard: {
type: Object,
default: null,
},
winner: {
type: String,
default: null,
validator: (value) => [null, 'player', 'opponent'].includes(value),
},
firstPlayer: {
type: String,
default: null,
validator: (value) => [null, 'player', 'opponent'].includes(value),
},
})
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="flex justify-center items-end relative h-40">
<TransitionGroup
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 -translate-y-8 scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100"
>
<div
v-for="(card, index) in cards"
:key="`${card.suit}-${card.rank}-${index}`"
class="absolute transition-all duration-300 ease-out"
:style="getCardStyle(index)"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = null"
@click="handleCardClick(card, index)"
>
<div
:class="[
'transition-all duration-300 rounded-lg',
isValid(card)
? 'cursor-pointer hover:shadow-xl ring-2 ring-transparent hover:ring-yellow-400/50'
: 'cursor-not-allowed opacity-60 grayscale brightness-75',
hoveredIndex === index && isValid(card) && 'scale-110 -translate-y-4 z-50',
]"
>
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import GameCard from './GameCard.vue'
import { toast } from 'vue-sonner'
import { useBiscaStore } from '@/stores/bisca'
const props = defineProps({
cards: {
type: Array,
required: true,
default: () => [],
},
faceDown: {
type: Boolean,
default: false,
},
maxCards: {
type: Number,
default: 3,
validator: (value) => [3, 9].includes(value),
},
})
const emit = defineEmits(['card-clicked'])
const store = useBiscaStore()
const hoveredIndex = ref(null)
const isValid = (card) => {
if (props.faceDown) return false
return store.canPlayCard(card)
}
const handleCardClick = (card, index) => {
if (props.faceDown) return
if (store.canPlayCard(card)) {
emit('card-clicked', { card, index })
} else {
toast.warning('Jogada Inválida', {
description: 'Tens de assistir ao naipe jogado!',
duration: 2000,
})
}
}
const overlapPercentage = computed(() => {
return props.maxCards === 3 ? 0.7 : 0.85
})
const cardWidth = 128
const getCardStyle = (index) => {
const totalCards = props.cards.length
const overlapOffset = cardWidth * (1 - overlapPercentage.value)
const totalWidth = cardWidth + (totalCards - 1) * overlapOffset
const startOffset = -totalWidth / 2 + cardWidth / 2
let left = startOffset + index * overlapOffset
if (hoveredIndex.value !== null && hoveredIndex.value !== index) {
if (isValid(props.cards[hoveredIndex.value])) {
if (index < hoveredIndex.value) {
left -= 10
} else if (index > hoveredIndex.value) {
left += 10
}
}
}
return {
left: `calc(50% + ${left}px)`,
zIndex: hoveredIndex.value === index ? 50 : index + 1,
}
}
</script>
@@ -0,0 +1,176 @@
<template>
<div
class="inline-flex bg-gray-900/80 backdrop-blur-sm border border-gray-700/50 shadow-xl rounded-md px-4 py-1.5 items-center gap-4"
>
<!-- Opponent Score (Left) -->
<div class="flex items-center gap-2">
<div class="flex flex-col items-start">
<span class="text-gray-400 text-[10px] uppercase tracking-wide mb-0.5">{{
opponentName
}}</span>
<span
class="text-2xl font-black tabular-nums transition-all duration-300"
:class="[
opponentScore > playerScore
? 'text-amber-400 drop-shadow-[0_0_12px_rgba(251,191,36,0.6)]'
: 'text-white',
scoreChanged === 'opponent' && 'scale-110',
]"
>
{{ displayOpponentScore }}
</span>
</div>
</div>
<!-- Center Turn Indicator -->
<div class="flex flex-col items-center gap-1">
<div
class="px-3 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider transition-all duration-300"
:class="[
currentTurn === 'player'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 shadow-[0_0_10px_rgba(52,211,153,0.4)]'
: 'bg-rose-500/20 text-rose-400 border border-rose-500/40 shadow-[0_0_10px_rgba(251,113,133,0.4)]',
turnChanged && 'animate-pulse',
]"
>
{{ currentTurn === 'player' ? 'Your Turn' : "Bot's Turn" }}
</div>
<span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium">
Round {{ roundNumber }}
</span>
</div>
<!-- Player Score (Right) -->
<div class="flex items-center gap-2">
<div class="flex flex-col items-end">
<span class="text-gray-400 text-[10px] uppercase tracking-wide mb-0.5">{{
playerName
}}</span>
<span
class="text-2xl font-black tabular-nums transition-all duration-300"
:class="[
playerScore > opponentScore
? 'text-amber-400 drop-shadow-[0_0_12px_rgba(251,191,36,0.6)]'
: 'text-white',
scoreChanged === 'player' && 'scale-110',
]"
>
{{ displayPlayerScore }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
const props = defineProps({
playerScore: {
type: Number,
required: true,
},
opponentScore: {
type: Number,
required: true,
},
playerName: {
type: String,
default: 'You',
},
opponentName: {
type: String,
default: 'Bot',
},
currentTurn: {
type: String,
default: 'player',
validator: (value) => ['player', 'opponent'].includes(value),
},
roundNumber: {
type: Number,
default: null,
},
})
const scoreChanged = ref(null)
const turnChanged = ref(false)
const displayPlayerScore = ref(props.playerScore)
const displayOpponentScore = ref(props.opponentScore)
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => {
const duration = 500 // Total animation duration in ms
const steps = 20 // Number of increments
const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps
let current = fromValue
let step = 0
const interval = setInterval(() => {
step++
current += increment
if (step >= steps) {
current = toValue
clearInterval(interval)
}
callback(Math.round(current))
}, stepDuration)
}
// Watch for player score changes and trigger count-up animation
watch(
() => props.playerScore,
(newScore, oldScore) => {
scoreChanged.value = 'player'
if (oldScore !== undefined && newScore !== oldScore) {
animateScore(oldScore, newScore, (value) => {
displayPlayerScore.value = value
})
} else {
displayPlayerScore.value = newScore
}
setTimeout(() => {
scoreChanged.value = null
}, 300)
},
{ immediate: true },
)
// Watch for opponent score changes and trigger count-up animation
watch(
() => props.opponentScore,
(newScore, oldScore) => {
scoreChanged.value = 'opponent'
if (oldScore !== undefined && newScore !== oldScore) {
animateScore(oldScore, newScore, (value) => {
displayOpponentScore.value = value
})
} else {
displayOpponentScore.value = newScore
}
setTimeout(() => {
scoreChanged.value = null
}, 300)
},
{ immediate: true },
)
// Watch for turn changes and trigger pulse animation
watch(
() => props.currentTurn,
() => {
turnChanged.value = true
setTimeout(() => {
turnChanged.value = false
}, 1000)
},
)
</script>
+21 -2
View File
@@ -15,14 +15,16 @@
</li>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem v-if="!userLoggedIn">
<NavigationMenuLink>
<RouterLink to="/login">Login</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem v-else>
<NavigationMenuLink>
<a @click.prevent="logoutClickHandler">Logout</a>
<a href="/home" @click.prevent="logoutClickHandler" class="cursor-pointer">Logout</a>
</NavigationMenuLink>
<NavigationMenuLink>
<RouterLink to="/user">Profile</RouterLink>
@@ -34,6 +36,8 @@
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useBiscaStore } from '@/stores/bisca'
import {
NavigationMenu,
NavigationMenuContent,
@@ -48,8 +52,23 @@ import router from '@/router';
const emits = defineEmits(['logout'])
const { userLoggedIn } = defineProps(['userLoggedIn'])
const biscaStore = useBiscaStore()
const logoutClickHandler = () => {
if (biscaStore.isGameRunning) {
const confirmLogout = window.confirm(
'⚠️ Jogo em Progresso!\n\nSe fizeres Logout agora, perderás o jogo atual e serás considerado PERDEDOR.\n\nQueres mesmo sair?'
)
if (!confirmLogout) return
biscaStore.isLoggingOut = true
biscaStore.quitGame()
return
}
emits('logout')
router.push('/login')
}
</script>
</script>
+342
View File
@@ -0,0 +1,342 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">
All Animations Test Page
</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<!-- Score Animation Tests -->
<div class="mb-6">
<h3 class="text-emerald-400 font-semibold mb-3">Score Count-Up Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="increasePlayerScore"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
+10 Player Score
</button>
<button
@click="increaseOpponentScore"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
+10 Opponent Score
</button>
<button
@click="resetScores"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Reset Scores
</button>
</div>
</div>
<!-- Turn Transition Tests -->
<div class="mb-6">
<h3 class="text-blue-400 font-semibold mb-3">Turn Transition Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="toggleTurn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Switch Turn
</button>
<button
@click="currentTurn = 'player'"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Your Turn
</button>
<button
@click="currentTurn = 'opponent'"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Bot's Turn
</button>
</div>
</div>
<!-- Trump Reveal Tests -->
<div class="mb-6">
<h3 class="text-amber-400 font-semibold mb-3">Trump Reveal Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="triggerTrumpReveal"
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition-colors"
>
Reveal Trump
</button>
</div>
</div>
<!-- Game Over Tests -->
<div class="mb-6">
<h3 class="text-purple-400 font-semibold mb-3">Game Over Screen</h3>
<div class="flex flex-wrap gap-3">
<button
@click="showGameOverPlayerWin"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="showGameOverOpponentWin"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="showGameOverDraw"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Draw
</button>
</div>
</div>
<!-- Full Sequence -->
<div>
<h3 class="text-pink-400 font-semibold mb-3">Complete Sequence</h3>
<div class="flex flex-wrap gap-3">
<button
@click="runFullSequence"
:disabled="isRunningSequence"
class="px-4 py-2 bg-pink-600 hover:bg-pink-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Run Full Animation Sequence
</button>
</div>
</div>
</div>
<!-- Game Board Preview -->
<div class="grid grid-rows-[auto_1fr] gap-8">
<!-- Score Display -->
<div class="flex justify-center">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Deck Area (with trump) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Trump Card</h3>
<DeckArea
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="false"
:reveal-trump="revealTrump"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>
<strong>Score Count-Up:</strong> Click "+10" buttons to see scores animate smoothly
from old to new value
</li>
<li>
<strong>Turn Transition:</strong> Switch turns to see the indicator pulse and glow with
color change
</li>
<li>
<strong>Trump Reveal:</strong> Triggers a 3-second pulse/glow animation on the trump
card with label
</li>
<li>
<strong>Game Over:</strong> Shows victory/defeat modal with score count-up and confetti
(on win)
</li>
<li>
<strong>Full Sequence:</strong> Runs all animations in order: trump reveal → turn
changes → score updates → game over
</li>
</ul>
</div>
</div>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="handlePlayAgain"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import GameOver from '@/components/game/GameOver.vue'
// Game state
const playerScore = ref(23)
const opponentScore = ref(15)
const currentTurn = ref('player')
const cardsRemaining = ref(30)
const revealTrump = ref(false)
const isRunningSequence = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Game Over state
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 8,
opponentTricks: 6,
})
// Score manipulation
const increasePlayerScore = () => {
playerScore.value += 10
}
const increaseOpponentScore = () => {
opponentScore.value += 10
}
const resetScores = () => {
playerScore.value = 0
opponentScore.value = 0
}
// Turn control
const toggleTurn = () => {
currentTurn.value = currentTurn.value === 'player' ? 'opponent' : 'player'
}
// Trump reveal
const triggerTrumpReveal = () => {
revealTrump.value = true
setTimeout(() => {
revealTrump.value = false
}, 100)
}
// Game Over screens
const showGameOverPlayerWin = () => {
playerScore.value = 67
opponentScore.value = 53
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
}
const showGameOverOpponentWin = () => {
playerScore.value = 45
opponentScore.value = 75
gameOverWinner.value = 'opponent'
gameOverStats.value = {
playerTricks: 7,
opponentTricks: 13,
}
gameOverVisible.value = true
}
const showGameOverDraw = () => {
playerScore.value = 60
opponentScore.value = 60
gameOverWinner.value = 'draw'
gameOverStats.value = {
playerTricks: 10,
opponentTricks: 10,
}
gameOverVisible.value = true
}
const handlePlayAgain = () => {
gameOverVisible.value = false
resetScores()
currentTurn.value = 'player'
cardsRemaining.value = 40
}
// Full animation sequence
const runFullSequence = async () => {
if (isRunningSequence.value) return
isRunningSequence.value = true
// Reset everything
resetScores()
currentTurn.value = 'player'
gameOverVisible.value = false
await new Promise((resolve) => setTimeout(resolve, 500))
// 1. Trump Reveal
console.log('1. Trump Reveal')
triggerTrumpReveal()
await new Promise((resolve) => setTimeout(resolve, 3500))
// 2. First turn
console.log('2. Player turn')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 3. Player scores
console.log('3. Player scores')
playerScore.value = 11
await new Promise((resolve) => setTimeout(resolve, 2000))
// 4. Switch turn
console.log('4. Switch to opponent turn')
currentTurn.value = 'opponent'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 5. Opponent scores
console.log('5. Opponent scores')
opponentScore.value = 10
await new Promise((resolve) => setTimeout(resolve, 2000))
// 6. Back to player
console.log('6. Back to player')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1000))
// 7. More scoring
console.log('7. More scoring')
playerScore.value = 23
await new Promise((resolve) => setTimeout(resolve, 1500))
opponentScore.value = 19
await new Promise((resolve) => setTimeout(resolve, 1500))
playerScore.value = 34
await new Promise((resolve) => setTimeout(resolve, 1500))
// 8. Final scores and game over
console.log('8. Game over')
playerScore.value = 67
opponentScore.value = 53
await new Promise((resolve) => setTimeout(resolve, 2000))
// Show game over
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
isRunningSequence.value = false
}
</script>
+252
View File
@@ -0,0 +1,252 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-4xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Animation Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3">
<button
@click="playPlayerCard"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
Play Player Card
</button>
<button
@click="playOpponentCard"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Play Opponent Card
</button>
<button
@click="playBothCards"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="clearCards"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Clear Cards
</button>
<button
@click="setPlayerWinner"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="setOpponentWinner"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="fullSequence"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition-colors"
>
Full Trick Sequence
</button>
<button
@click="collectTrickPlayerWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Player Wins)
</button>
<button
@click="collectTrickOpponentWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Opponent Wins)
</button>
</div>
<!-- Current State Display -->
<div class="mt-4 text-gray-300 text-sm">
<p>
<strong>Player Card:</strong>
{{ playerCard ? `${playerCard.suit}${playerCard.rank}` : 'None' }}
</p>
<p>
<strong>Opponent Card:</strong>
{{ opponentCard ? `${opponentCard.suit}${opponentCard.rank}` : 'None' }}
</p>
<p><strong>Winner:</strong> {{ winner || 'None' }}</p>
<p><strong>First Player:</strong> {{ firstPlayer || 'None' }}</p>
</div>
</div>
<!-- PlayArea Component -->
<div class="bg-gray-800/50 rounded-lg p-8">
<h2 class="text-white text-xl font-semibold mb-4 text-center">Play Area</h2>
<PlayArea
:player-card="playerCard"
:opponent-card="opponentCard"
:winner="winner"
:first-player="firstPlayer"
/>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Play Player Card:</strong> Animates a card from bottom (player's hand)</li>
<li><strong>Play Opponent Card:</strong> Animates a card from top (opponent's hand)</li>
<li><strong>Play Both Cards:</strong> Both cards animate in simultaneously</li>
<li><strong>Clear Cards:</strong> Triggers exit animations (cards slide out)</li>
<li>
<strong>Winner Buttons:</strong> Add glowing ring effect to winning card (play cards
first)
</li>
<li>
<strong>Full Trick Sequence:</strong> Plays both cards shows winner clears (2 second
delay)
</li>
<li>
<strong>Collect Trick (Player Wins):</strong> Sets player as winner, waits 1.5s, then
cards slide DOWN toward player area
</li>
<li>
<strong>Collect Trick (Opponent Wins):</strong> Sets opponent as winner, waits 1.5s,
then cards slide UP toward opponent area
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PlayArea from '@/components/game/PlayArea.vue'
// Reactive state
const playerCard = ref(null)
const opponentCard = ref(null)
const winner = ref(null)
const firstPlayer = ref(null)
// Sample cards to test with
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 7 },
]
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Control functions
const playPlayerCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'player'
}
playerCard.value = getRandomCard()
winner.value = null
}
const playOpponentCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'opponent'
}
opponentCard.value = getRandomCard()
winner.value = null
}
const playBothCards = () => {
firstPlayer.value = 'player' // Player plays first in this scenario
playerCard.value = getRandomCard()
opponentCard.value = getRandomCard()
winner.value = null
}
const clearCards = () => {
playerCard.value = null
opponentCard.value = null
winner.value = null
firstPlayer.value = null
}
const setPlayerWinner = () => {
if (playerCard.value) {
winner.value = 'player'
}
}
const setOpponentWinner = () => {
if (opponentCard.value) {
winner.value = 'opponent'
}
}
const fullSequence = async () => {
// Clear first
clearCards()
await new Promise((resolve) => setTimeout(resolve, 500))
// Play both cards
playBothCards()
await new Promise((resolve) => setTimeout(resolve, 1000))
// Show winner (randomly)
winner.value = Math.random() > 0.5 ? 'player' : 'opponent'
await new Promise((resolve) => setTimeout(resolve, 2000))
// Clear cards
clearCards()
}
// Collect trick with player winning
const collectTrickPlayerWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set player as winner
winner.value = 'player'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward player)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
// Collect trick with opponent winning
const collectTrickOpponentWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set opponent as winner
winner.value = 'opponent'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward opponent)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
</script>
+275
View File
@@ -0,0 +1,275 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Card Dealing Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3 mb-4">
<button
@click="startDealing"
:disabled="isDealing"
class="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Start Dealing
</button>
<button
@click="dealSingleCard"
:disabled="isDealing || cardsRemaining === 0"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Deal One Card
</button>
<button
@click="reset"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Reset
</button>
</div>
<!-- Current State Display -->
<div class="grid grid-cols-3 gap-4 text-gray-300 text-sm">
<div>
<p><strong>Player Hand:</strong> {{ playerHand.length }} cards</p>
<p><strong>Opponent Hand:</strong> {{ opponentHand.length }} cards</p>
</div>
<div>
<p><strong>Cards Remaining:</strong> {{ cardsRemaining }}</p>
<p><strong>Dealing:</strong> {{ isDealing ? 'Yes' : 'No' }}</p>
</div>
<div>
<p><strong>Next Recipient:</strong> {{ nextRecipient }}</p>
</div>
</div>
</div>
<!-- Game Board Layout -->
<div class="grid grid-rows-[auto_1fr_auto] gap-8">
<!-- Opponent Hand (Top) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Opponent Hand</h3>
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
<!-- Center Area (Deck) -->
<div class="flex items-center justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Deck</h3>
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
/>
</div>
</div>
<!-- Player Hand (Bottom) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Your Hand</h3>
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Start Dealing:</strong> Automatically deals 6 cards (3 to each player) with 200ms delay</li>
<li><strong>Deal One Card:</strong> Manually deal a single card to the next recipient</li>
<li><strong>Reset:</strong> Clear all hands and reset the deck to 40 cards</li>
<li>Watch the FlyingCard animation as cards move from deck to hands</li>
<li>Notice the deck counter decreasing and the pulse effect</li>
<li>Cards appear in hands with a fade-in animation</li>
</ul>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
// Refs for components
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const cardsRemaining = ref(40)
const isDealing = ref(false)
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Trump card (fixed for testing)
const trumpCard = ref({ suit: 'o', rank: 7 })
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Computed
const nextRecipient = computed(() => {
const totalDealt = playerHand.value.length + opponentHand.value.length
return totalDealt % 2 === 0 ? 'Player' : 'Opponent'
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get position of an element
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64, // Center, minus half card width
y: rect.top + rect.height / 2 - 89, // Center, minus half card height
}
}
// Deal a single card
const dealSingleCard = async () => {
if (cardsRemaining.value === 0) return
await nextTick()
// Determine recipient
const totalDealt = playerHand.value.length + opponentHand.value.length
const isPlayer = totalDealt % 2 === 0
// Get positions
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
// Create flying card
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer, // Face down for opponent
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card animation complete
const onFlyingCardComplete = (flyingCard) => {
// Add card to appropriate hand
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
// Remove flying card
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Start automatic dealing sequence
const startDealing = async () => {
if (isDealing.value) return
isDealing.value = true
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
await dealSingleCard()
// Wait 200ms before next card
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Reset everything
const reset = () => {
playerHand.value = []
opponentHand.value = []
cardsRemaining.value = 40
flyingCards.value = []
isDealing.value = false
}
</script>
+444
View File
@@ -0,0 +1,444 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900">
<!-- Control Panel (Floating) -->
<div class="fixed top-4 left-4 bg-gray-900/95 rounded-lg p-4 max-w-xs z-50 shadow-2xl">
<h3 class="text-white font-bold mb-3 text-sm">Test Controls</h3>
<div class="space-y-2">
<button
@click="dealInitialCards"
:disabled="isDealing"
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Deal Cards
</button>
<button
@click="playRandomCards"
:disabled="playerHand.length === 0"
class="w-full px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="collectTrick"
:disabled="!currentTrick.playerCard || !currentTrick.opponentCard"
class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Collect Trick
</button>
<button
@click="revealTrump = !revealTrump"
class="w-full px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded text-xs font-medium transition-colors"
>
{{ revealTrump ? 'Hide' : 'Reveal' }} Trump
</button>
<button
@click="showGameOver"
class="w-full px-3 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded text-xs font-medium transition-colors"
>
Show Game Over
</button>
<button
@click="resetGame"
class="w-full px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded text-xs font-medium transition-colors"
>
Reset Game
</button>
<div class="pt-2 border-t border-gray-700 mt-2 text-gray-400 text-xs">
<p><strong>Turn:</strong> {{ currentTurn === 'player' ? 'You' : 'Bot' }}</p>
<p><strong>Deck:</strong> {{ cardsRemaining }} cards</p>
</div>
</div>
</div>
<!-- Main Game Board -->
<div
class="grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8 min-h-screen"
>
<!-- Score Display (Top Left) -->
<div class="col-start-1 col-end-4 row-start-1 flex justify-start items-start">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Opponent Hand -->
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
<!-- Play Area -->
<div class="col-start-2 col-end-3 row-start-3 flex items-center justify-center">
<PlayArea
:player-card="currentTrick.playerCard"
:opponent-card="currentTrick.opponentCard"
:winner="currentTrick.winner"
:first-player="currentTrick.firstPlayer"
/>
</div>
<!-- Deck Area -->
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
:reveal-trump="revealTrump"
/>
</div>
<!-- Player Hand -->
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="playableCards"
@card-clicked="handleCardClick"
/>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="resetGame"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import GameBoard from '@/components/game/GameBoard.vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import PlayArea from '@/components/game/PlayArea.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
import GameOver from '@/components/game/GameOver.vue'
// Component refs
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const playerScore = ref(0)
const opponentScore = ref(0)
const currentTurn = ref('player')
const cardsRemaining = ref(40)
const isDealing = ref(false)
const revealTrump = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Current trick
const currentTrick = ref({
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
})
// Flying cards for dealing animation
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Game Over
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 0,
opponentTricks: 0,
})
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Playable cards (all cards in hand for testing)
const playableCards = computed(() => {
return playerHand.value.map((_, index) => index)
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get element position
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64,
y: rect.top + rect.height / 2 - 89,
}
}
// Deal a single card
const dealSingleCard = async (isPlayer) => {
if (cardsRemaining.value === 0) return
await nextTick()
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer,
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card complete
const onFlyingCardComplete = (flyingCard) => {
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Deal initial cards
const dealInitialCards = async () => {
if (isDealing.value) return
isDealing.value = true
// Reveal trump at start
revealTrump.value = true
await new Promise((resolve) => setTimeout(resolve, 1000))
revealTrump.value = false
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
const isPlayer = i % 2 === 0
await dealSingleCard(isPlayer)
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Handle card click from player hand
const handleCardClick = ({ card, index }) => {
if (currentTurn.value !== 'player') return
if (currentTrick.value.playerCard) return
// Set first player if no cards played yet
if (!currentTrick.value.opponentCard) {
currentTrick.value.firstPlayer = 'player'
}
// Play the card
currentTrick.value.playerCard = card
playerHand.value.splice(index, 1)
// Switch turn
currentTurn.value = 'opponent'
// Auto-play opponent card after delay
setTimeout(() => {
if (opponentHand.value.length > 0 && !currentTrick.value.opponentCard) {
playOpponentCard()
}
}, 1000)
}
// Play opponent card
const playOpponentCard = () => {
if (opponentHand.value.length === 0) return
// Set first player if no cards played yet
if (!currentTrick.value.playerCard) {
currentTrick.value.firstPlayer = 'opponent'
}
// Play random card
const randomIndex = Math.floor(Math.random() * opponentHand.value.length)
const card = opponentHand.value[randomIndex]
currentTrick.value.opponentCard = card
opponentHand.value.splice(randomIndex, 1)
// Switch turn
currentTurn.value = 'player'
}
// Play random cards from both players
const playRandomCards = () => {
if (playerHand.value.length === 0) return
// Player plays first
currentTrick.value.firstPlayer = 'player'
const playerCard = playerHand.value[0]
currentTrick.value.playerCard = playerCard
playerHand.value.shift()
// Opponent plays after delay
setTimeout(() => {
if (opponentHand.value.length > 0) {
const opponentCard = opponentHand.value[0]
currentTrick.value.opponentCard = opponentCard
opponentHand.value.shift()
}
}, 600)
}
// Collect trick
const collectTrick = async () => {
if (!currentTrick.value.playerCard || !currentTrick.value.opponentCard) return
// Randomly determine winner
const winner = Math.random() > 0.5 ? 'player' : 'opponent'
currentTrick.value.winner = winner
// Wait to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Update score (random points)
const points = Math.floor(Math.random() * 15) + 5
if (winner === 'player') {
playerScore.value += points
gameOverStats.value.playerTricks++
} else {
opponentScore.value += points
gameOverStats.value.opponentTricks++
}
// Clear cards (triggers collection animation)
currentTrick.value.playerCard = null
currentTrick.value.opponentCard = null
// Reset after animation
await new Promise((resolve) => setTimeout(resolve, 600))
currentTrick.value.winner = null
currentTrick.value.firstPlayer = null
// Deal new cards if deck has cards
if (cardsRemaining.value >= 2 && playerHand.value.length < 3) {
await new Promise((resolve) => setTimeout(resolve, 500))
await dealSingleCard(winner === 'player')
await new Promise((resolve) => setTimeout(resolve, 700))
await dealSingleCard(winner === 'opponent')
}
// Switch turn to winner
currentTurn.value = winner
}
// Show game over
const showGameOver = () => {
// Make player win for demo
if (playerScore.value <= opponentScore.value) {
playerScore.value = opponentScore.value + 10
}
gameOverWinner.value =
playerScore.value > opponentScore.value
? 'player'
: opponentScore.value > playerScore.value
? 'opponent'
: 'draw'
gameOverVisible.value = true
}
// Reset game
const resetGame = () => {
playerHand.value = []
opponentHand.value = []
playerScore.value = 0
opponentScore.value = 0
currentTurn.value = 'player'
cardsRemaining.value = 40
currentTrick.value = {
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
}
flyingCards.value = []
gameOverVisible.value = false
gameOverStats.value = {
playerTricks: 0,
opponentTricks: 0,
}
revealTrump.value = false
}
</script>
@@ -0,0 +1,142 @@
<template>
<div class="relative min-h-screen overflow-hidden">
<div v-if="store.isGameRunning" 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>
<div v-if="store.isGameRunning">
<GameBoard
:trump-card="store.trumpCard"
:cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)"
:player-hand="store.playerHand"
:opponent-hand="store.opponentHand"
:player-score="store.playerScore"
:opponent-score="store.opponentScore"
:current-turn="store.currentTurn"
:current-trick="store.table"
@play-card="handlePlayCard"
/>
</div>
<div v-else class="flex h-screen items-center justify-center bg-green-900 text-white">
<div class="animate-pulse text-xl">Preparing game...</div>
</div>
<GameOver
:is-visible="store.isGameOver"
:winner="store.winner || 'draw'"
:player-score="store.playerScore"
:opponent-score="store.opponentScore"
:stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }"
:is-logging-out="store.isLoggingOut"
@play-again="handlePlayAgain"
@close="handleCloseModal"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, onBeforeMount } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { toast } from 'vue-sonner' // Import Toast for feedback
import { useBiscaStore } from '@/stores/bisca'
import { useAuthStore } from '@/stores/auth'
import GameBoard from '@/components/game/GameBoard.vue'
import GameOver from '@/components/game/GameOver.vue'
const props = defineProps({
gameType: {
type: Number,
default: 3,
},
})
const store = useBiscaStore()
const router = useRouter()
const authStore = useAuthStore()
onBeforeMount(() => {
store.isGameOver = false
})
onMounted(() => {
store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBrowserUnload)
store.isGameOver = false
store.isGameRunning = false
store.isLoggingOut = false
})
const handlePlayCard = (card) => {
store.playerPlayCard(card)
}
const handlePlayAgain = () => {
store.startGame(props.gameType)
}
const handleSurrender = () => {
const confirmSurrender = window.confirm(
'Are you sure you want to surrender? Your opponent will be awarded the win.',
)
if (!confirmSurrender) return
store.quitGame()
toast.error('Game Surrendered', {
description: 'You forfeited the match.',
})
router.push({ name: 'home' })
}
const handleCloseModal = () => {
if (store.isLoggingOut) {
localStorage.removeItem('token')
authStore.logout()
store.$reset()
router.push({ name: 'login' })
return
}
store.isGameRunning = false
store.isGameOver = false
router.push({ name: 'home' })
}
const handleBrowserUnload = (event) => {
if (store.isGameRunning) {
event.preventDefault()
event.returnValue = ''
}
}
onBeforeRouteLeave((to, from, next) => {
if (!store.isGameRunning) {
next()
return
}
const confirmExit = window.confirm(
'⚠️ Game in Progress!\n\nIf you leave now, the game will be cancelled and recorded as a LOSS.\n\nAre you sure you want to leave?',
)
if (confirmExit) {
store.quitGame()
next()
} else {
next(false)
}
})
</script>
+76 -2
View File
@@ -1,11 +1,85 @@
<template>
<div>
<div
class="flex flex-col items-center justify-center min-h-screen bg-green-900 text-white relative"
>
<h1 class="text-6xl font-bold mb-12 drop-shadow-lg">Bisca Game</h1>
<div v-if="!showModeSelection" class="flex flex-col gap-6 w-64">
<button
@click="showModeSelection = true"
class="px-6 py-4 bg-emerald-600 hover:bg-emerald-500 rounded-lg text-xl font-bold shadow-lg transition-transform hover:scale-105"
>
Single Player
</button>
<button
disabled
class="px-6 py-4 bg-gray-600 rounded-lg text-xl font-bold opacity-50 cursor-not-allowed"
>
Multiplayer
</button>
</div>
<div
v-else
class="bg-gray-800/90 p-8 rounded-xl border border-gray-600 shadow-2xl w-80 text-center animate-fade-in"
>
<h2 class="text-2xl font-bold mb-6 text-emerald-400">Escolhe o Modo</h2>
<div class="flex flex-col gap-4">
<button
@click="startGame(3)"
class="px-6 py-3 bg-blue-600 hover:bg-blue-500 rounded-lg font-bold transition-colors flex justify-between items-center"
>
<span>Bisca de 3</span>
<span class="text-xs bg-blue-800 px-2 py-1 rounded">Clássico</span>
</button>
<button
@click="startGame(9)"
class="px-6 py-3 bg-purple-600 hover:bg-purple-500 rounded-lg font-bold transition-colors flex justify-between items-center"
>
<span>Bisca de 9</span>
<span class="text-xs bg-purple-800 px-2 py-1 rounded">Épico</span>
</button>
</div>
<button
@click="showModeSelection = false"
class="mt-6 text-gray-400 hover:text-white text-sm underline"
>
Voltar atrás
</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const showModeSelection = ref(false)
const startGame = (type) => {
// Navega para a rota correta baseada no tipo (3 ou 9)
const routeName = type === 9 ? 'bisca9' : 'bisca3'
router.push({ name: routeName })
}
</script>
<style scoped></style>`
<style scoped>
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
+51
View File
@@ -4,17 +4,39 @@ import LaravelPage from '@/pages/testing/LaravelPage.vue'
import WebsocketsPage from '@/pages/testing/WebsocketsPage.vue'
import UserPage from '@/pages/user/UserPage.vue'
import { createRouter, createWebHistory } from 'vue-router'
import { toast } from 'vue-sonner'
import TestAnimations from '@/pages/TestAnimations.vue'
import TestDealing from '@/pages/TestDealing.vue'
import TestAllAnimations from '@/pages/TestAllAnimations.vue'
import TestGameBoard from '@/pages/TestGameBoard.vue'
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomePage,
},
{
path: '/login',
name: 'login',
component: LoginPage,
},
{
path: '/game/3',
name: 'bisca3',
component: SinglePlayerGamePage,
props: { gameType: 3 },
meta: { requiresAuth: true },
},
{
path: '/game/9',
name: 'bisca9',
component: SinglePlayerGamePage,
props: { gameType: 9 },
meta: { requiresAuth: true },
},
{
path: '/user',
@@ -31,9 +53,38 @@ const router = createRouter({
path: 'websockets',
component: WebsocketsPage,
},
{
path: 'animations',
component: TestAnimations,
},
{
path: 'dealing',
component: TestDealing,
},
{
path: 'all-animations',
component: TestAllAnimations,
},
{
path: 'gameboard',
component: TestGameBoard,
},
],
},
],
})
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
if (requiresAuth) {
const token = localStorage.getItem('token')
if (!token) {
return next({ name: 'login' })
}
}
next()
})
export default router
+323
View File
@@ -0,0 +1,323 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useBiscaStore = defineStore('bisca', () => {
const isDealing = ref(false)
const isGameRunning = ref(false)
const isGameOver = ref(false)
const gameType = ref(3)
const isLoggingOut = ref(false)
const deck = ref([])
const playerHand = ref([])
const opponentHand = ref([])
const trumpCard = ref(null)
const trumpSuit = ref('')
const playerScore = ref(0)
const opponentScore = ref(0)
const currentTurn = ref('player')
const playerTricks = ref(0)
const opponentTricks = ref(0)
const winner = ref(null)
const table = ref({
playerCard: null,
opponentCard: null,
firstToPlay: null,
})
const startGame = async (type = 3) => {
if (isDealing.value) return
isDealing.value = true
isGameRunning.value = true
gameType.value = type
isGameOver.value = false
isLoggingOut.value = false
playerScore.value = 0
opponentScore.value = 0
playerTricks.value = 0
opponentTricks.value = 0
deck.value = []
playerHand.value = []
opponentHand.value = []
trumpCard.value = null
trumpSuit.value = ''
currentTurn.value = 'player'
table.value = { playerCard: null, opponentCard: null, firstToPlay: null }
deck.value = createDeck()
const lastCard = deck.value.pop()
trumpCard.value = lastCard
trumpSuit.value = lastCard.suit
await wait(500)
await dealInitialCards()
isDealing.value = false
if (currentTurn.value === 'opponent') {
setTimeout(botPlayCard, 1000)
}
}
const playerPlayCard = (card) => {
if (!isGameRunning.value || currentTurn.value !== 'player') return
if (table.value.playerCard) return
if (!canPlayCard(card)) {
console.warn('Invalid play! Must follow suit if possible')
return
}
playerHand.value = playerHand.value.filter((c) => c.id !== card.id)
table.value.playerCard = card
if (!table.value.firstToPlay) table.value.firstToPlay = 'player'
checkEndOfTrick()
}
const botPlayCard = () => {
if (!isGameRunning.value) return
if (opponentHand.value.length === 0) return
let cardToPlay = null
if (table.value.playerCard) {
const pCard = table.value.playerCard
const winningCards = opponentHand.value.filter((botCard) => doesCardWin(botCard, pCard))
if (winningCards.length > 0) {
winningCards.sort((a, b) => a.strength - b.strength)
cardToPlay = winningCards[0]
} else {
const sortedHand = [...opponentHand.value].sort((a, b) => {
if (a.points === b.points) return a.strength - b.strength
return a.points - b.points
})
cardToPlay = sortedHand[0]
}
} else {
const sortedHand = [...opponentHand.value].sort((a, b) => {
if (a.suit === trumpSuit.value && b.suit !== trumpSuit.value) return 1
if (a.suit !== trumpSuit.value && b.suit === trumpSuit.value) return -1
return a.strength - b.strength
})
cardToPlay = sortedHand[0]
table.value.firstToPlay = 'opponent'
}
opponentHand.value = opponentHand.value.filter((c) => c.id !== cardToPlay.id)
table.value.opponentCard = cardToPlay
checkEndOfTrick()
}
const checkEndOfTrick = () => {
if (table.value.playerCard && table.value.opponentCard) {
currentTurn.value = 'resolving'
setTimeout(resolveTrick, 1500)
} else {
if (table.value.playerCard) {
currentTurn.value = 'opponent'
setTimeout(botPlayCard, 1000)
} else {
currentTurn.value = 'player'
}
}
}
const resolveTrick = async () => {
const pCard = table.value.playerCard
const oCard = table.value.opponentCard
let weWon = false
if (table.value.firstToPlay === 'player') {
if (doesCardWin(oCard, pCard)) weWon = false
else weWon = true
} else {
if (doesCardWin(pCard, oCard)) weWon = true
else weWon = false
}
const points = pCard.points + oCard.points
if (weWon) {
playerScore.value += points
playerTricks.value++
currentTurn.value = 'player'
} else {
opponentScore.value += points
opponentTricks.value++
currentTurn.value = 'opponent'
}
table.value = { playerCard: null, opponentCard: null, firstToPlay: null }
if (deck.value.length > 0 || trumpCard.value !== null) {
if (weWon) {
await drawOneCard('player')
await drawOneCard('opponent')
} else {
await drawOneCard('opponent')
await drawOneCard('player')
}
} else if (playerHand.value.length === 0 && opponentHand.value.length === 0) {
endGame()
return
}
if (currentTurn.value === 'opponent') {
setTimeout(botPlayCard, 1000)
}
}
const drawOneCard = async (who) => {
let cardToDraw = null
if (deck.value.length > 0) {
cardToDraw = deck.value.pop()
} else if (trumpCard.value !== null) {
cardToDraw = trumpCard.value
trumpCard.value = null
} else {
return
}
if (who === 'player') {
playerHand.value.push(cardToDraw)
} else {
opponentHand.value.push(cardToDraw)
}
}
const doesCardWin = (attacker, defender) => {
const suit = trumpSuit.value
if (attacker.suit === suit && defender.suit !== suit) return true
if (defender.suit === suit && attacker.suit !== suit) return false
if (attacker.suit === defender.suit) {
return attacker.strength > defender.strength
}
return false
}
const endGame = () => {
isGameRunning.value = false
isGameOver.value = true
if (playerScore.value > opponentScore.value) winner.value = 'player'
else if (opponentScore.value > playerScore.value) winner.value = 'opponent'
else winner.value = 'draw'
}
const dealInitialCards = async () => {
const cardsToDeal = gameType.value === 9 ? 9 : 3
const dealSpeed = gameType.value === 9 ? 200 : 500
for (let i = 0; i < cardsToDeal; i++) {
if (deck.value.length > 0) {
playerHand.value.push(deck.value.pop())
await wait(dealSpeed)
}
if (deck.value.length > 0) {
opponentHand.value.push(deck.value.pop())
await wait(dealSpeed)
}
}
}
const createDeck = () => {
const suits = ['c', 'e', 'o', 'p']
const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13]
let newDeck = []
for (const suit of suits) {
for (const rank of ranks) {
newDeck.push({
id: `${suit}-${rank}`,
suit,
rank,
points: getPoints(rank),
strength: getStrength(rank),
})
}
}
return newDeck.sort(() => Math.random() - 0.5)
}
const getPoints = (rank) => {
if (rank === 1) return 11
if (rank === 7) return 10
if (rank === 13) return 4
if (rank === 11) return 3
if (rank === 12) return 2
return 0
}
const getStrength = (rank) => {
const strengthMap = { 1: 10, 7: 9, 13: 8, 11: 7, 12: 6, 6: 5, 5: 4, 4: 3, 3: 2, 2: 1 }
return strengthMap[rank] || 0
}
const quitGame = () => {
if (!isGameRunning.value) return
opponentScore.value = 120
playerScore.value = 0
winner.value = 'opponent'
// Parar o jogo imediatamente
isGameRunning.value = false
isGameOver.value = true
}
const canPlayCard = (card) => {
if (currentTurn.value !== 'player') return false
if (!table.value.opponentCard) return true
const hasCardsToDraw = deck.value.length > 0 || trumpCard.value !== null
if (hasCardsToDraw) return true
const suitToFollow = table.value.opponentCard.suit
const hasSuit = playerHand.value.some((c) => c.suit === suitToFollow)
if (hasSuit && card.suit !== suitToFollow) {
return false
}
return true
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
return {
isDealing,
isGameRunning,
gameType,
deck,
playerHand,
opponentHand,
isLoggingOut,
trumpCard,
trumpSuit,
playerScore,
opponentScore,
currentTurn,
table,
playerTricks,
opponentTricks,
isGameOver,
winner,
startGame,
quitGame,
resolveTrick,
playerPlayCard,
canPlayCard,
}
})
-8
View File
@@ -1,8 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { toast } from 'vue-sonner'
import { useApiStore } from './api'
export const useGameStore = defineStore('game', () => {
})
+2 -1
View File
@@ -1 +1,2 @@
PORT=
PORT=
API_URL=
+19
View File
@@ -0,0 +1,19 @@
import axios from "axios";
import { getUser } from "../state/connection.js";
import { createGame, joinGame, playCard, getGame } from "../state/game.js";
const API_URL = process.env.API_URL || "http://localhost:8000/api";
export const handleGameEvents = (io, socket) => {
socket.on("create-game", async (data) => {
try {
const user = getUser(socket.id);
const game = createGame(data.type, user);
socket.join(`game_${game.match_id}`);
} catch (error) {
console.error("Error creating game:", error);
socket.emit("error", { message: "Failed to create game." });
}
});
};
+186
View File
@@ -0,0 +1,186 @@
const games = new Map();
let currentGameID = 0;
// Bisca deck configuration
const suits = ["hearts", "diamonds", "clubs", "spades"];
const cards = [
{ face: "A", value: 11, points: 11 },
{ face: "7", value: 10, points: 10 },
{ face: "K", value: 4, points: 4 },
{ face: "J", value: 3, points: 3 },
{ face: "Q", value: 2, points: 2 },
{ face: "6", value: 0, points: 0 },
{ face: "5", value: 0, points: 0 },
{ face: "4", value: 0, points: 0 },
{ face: "3", value: 0, points: 0 },
{ face: "2", value: 0, points: 0 },
];
const createDeck = () => {
const deck = [];
suits.forEach((suit) => {
cards.forEach((card) => {
deck.push({ ...card, suit });
});
});
return deck;
};
const shuffleDeck = (deck) => {
const shuffled = [...deck];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const dealCards = (deck, cardsPerPlayer = 3) => {
const player1Hand = deck.slice(0, cardsPerPlayer);
const player2Hand = deck.slice(cardsPerPlayer, cardsPerPlayer * 2);
const trumpCard = deck.pop();
return {
player1Hand,
player2Hand,
trumpCard,
remainingDeck: deck,
};
};
export const createGame = (type, host) => {
const gameID = currentGameID++;
const game = {
match_id: gameID,
type: type,
player1_user_id: host.id,
player2_user_id: null,
is_draw: false,
winner_user_id: null,
loser_user_id: null,
status: "pending",
began_at: null,
ended_at: null,
player1_points: 0,
player2_points: 0,
// Game-specific state
player1_hand: [],
player2_hand: [],
trump_card: null,
trump_suit: null,
current_trick: [],
current_player: null,
round_number: 0,
};
games.set(gameID, game);
return game;
};
export const joinGame = (gameID, player2) => {
const game = games.get(gameID);
game.player2_user_id = player2.id;
game.status = "playing";
game.began_at = new Date();
const cardCount = parseInt(game.type, 10);
if (![3, 9].includes(cardCount)) {
throw new Error("Unsupported game type");
}
// Initialize game state
const deck = shuffleDeck(createDeck());
const { player1Hand, player2Hand, trumpCard, remainingDeck } = dealCards(
deck,
cardCount
);
game.player1_hand = player1Hand;
game.player2_hand = player2Hand;
game.trump_card = trumpCard;
game.trump_suit = trumpCard.suit;
game.remaining_deck = remainingDeck;
game.current_player = game.player1_user_id; // Player 1 starts
return game;
};
export const playCard = (gameID, playerID, cardIndex) => {
const game = games.get(gameID);
if (game.current_player !== playerID) {
throw new Error("Not this player's turn");
}
const isPlayer1 = playerID === game.player1_user_id;
const playerHand = isPlayer1 ? game.player1_hand : game.player2_hand;
const playedCard = playerHand.splice(cardIndex, 1)[0];
game.current_trick.push({ playerID, card: playedCard });
if (game.current_trick.length === 2) {
resolveTrick(game);
} else {
game.current_player = isPlayer1
? game.player2_user_id
: game.player1_user_id;
}
return game;
};
const resolveTrick = (game) => {
const [first, second] = game.current_trick;
let winner;
if (first.card.suit === second.card.suit) {
winner = first.card.value > second.card.value ? first : second;
} else if (second.card.suit === game.trump_suit) {
winner = second;
} else {
winner = first; // First card wins if no trump and different suits
}
const points = first.card.points + second.card.points;
winner.playerID === game.player1_user_id
? (game.player1_points += points)
: (game.player2_points += points);
if (deck.length > 0) {
const winnerHand =
winner.playerID === game.player1_user_id
? game.player1_hand
: game.player2_hand;
loserHand =
winner.playerID === game.player1_user_id
? game.player2_hand
: game.player1_hand;
winnerHand.push(game.deck.shift());
if (game.deck.length > 0) {
loserHand.push(game.deck.shift());
}
}
game.current_trick = [];
game.current_player = winner.playerID;
game.round_number += 1;
if (game.player1_hand.length === 0 && game.player2_hand.length === 0) {
endGame(game);
}
};
const endGame = (game) => {
game.status = "ended";
game.ended_at = new Date();
if (game.player1_points > game.player2_points) {
game.winner_user_id = game.player1_user_id;
game.loser_user_id = game.player2_user_id;
} else if (game.player2_points > game.player1_points) {
game.winner_user_id = game.player2_user_id;
game.loser_user_id = game.player1_user_id;
} else {
game.is_draw = true;
}
};
export const getGame = (gameID) => games.get(gameID);
export const getAllGames = () => Array.from(games.values());