feat: gameboard ui
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="relative flex items-center justify-center w-64 h-48">
|
||||
<div
|
||||
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,
|
||||
required: true,
|
||||
},
|
||||
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,116 @@
|
||||
<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"
|
||||
>
|
||||
<!-- 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
|
||||
:cards="opponentHand"
|
||||
:face-down="true"
|
||||
:max-cards="maxCards"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Deck Area -->
|
||||
<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>
|
||||
|
||||
<!-- Player Hand -->
|
||||
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
|
||||
<PlayerHand
|
||||
:cards="playerHand"
|
||||
:face-down="false"
|
||||
:max-cards="maxCards"
|
||||
:playable-cards="playableCardIndices"
|
||||
@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'
|
||||
|
||||
// Props for testing - in real implementation, these come from Pinia store
|
||||
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,
|
||||
},
|
||||
playableCardIndices: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
playerScore: {
|
||||
type: Number,
|
||||
default: 45,
|
||||
},
|
||||
opponentScore: {
|
||||
type: Number,
|
||||
default: 23,
|
||||
},
|
||||
currentTurn: {
|
||||
type: String,
|
||||
default: 'player',
|
||||
validator: (value) => ['player', 'opponent'].includes(value),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['card-clicked'])
|
||||
|
||||
const handleCardClick = (payload) => {
|
||||
emit('card-clicked', payload)
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,295 @@
|
||||
<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
|
||||
@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 duration-200 hover:scale-105 active:scale-95 shadow-lg"
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
<button
|
||||
v-if="!hideClose"
|
||||
@click="handleClose"
|
||||
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-6 rounded-lg transition-all duration-200 hover:scale-105 active:scale-95"
|
||||
>
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,98 @@
|
||||
<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="$emit('card-clicked', { card, index })"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'transition-all duration-300',
|
||||
isPlayable(index) && 'ring-2 ring-yellow-400 ring-offset-2 rounded-lg',
|
||||
hoveredIndex === index && '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'
|
||||
|
||||
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),
|
||||
},
|
||||
playableCards: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['card-clicked'])
|
||||
|
||||
const hoveredIndex = ref(null)
|
||||
|
||||
// Calculate overlap percentage based on max cards
|
||||
// 3 cards: 70% overlap, 9 cards: 85% overlap
|
||||
const overlapPercentage = computed(() => {
|
||||
return props.maxCards === 3 ? 0.7 : 0.85
|
||||
})
|
||||
|
||||
// Calculate card spacing dynamically
|
||||
const cardWidth = 128 // w-32 from GameCard component
|
||||
const getCardStyle = (index) => {
|
||||
const totalCards = props.cards.length
|
||||
const overlapOffset = cardWidth * (1 - overlapPercentage.value)
|
||||
|
||||
// Calculate total width needed
|
||||
const totalWidth = cardWidth + (totalCards - 1) * overlapOffset
|
||||
const startOffset = -totalWidth / 2 + cardWidth / 2
|
||||
|
||||
// Base position
|
||||
let left = startOffset + index * overlapOffset
|
||||
|
||||
// If a card is hovered, shift neighbors
|
||||
if (hoveredIndex.value !== null && hoveredIndex.value !== index) {
|
||||
if (index < hoveredIndex.value) {
|
||||
left -= 10 // Push left cards more to the left
|
||||
} else if (index > hoveredIndex.value) {
|
||||
left += 10 // Push right cards more to the right
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
left: `calc(50% + ${left}px)`,
|
||||
zIndex: hoveredIndex.value === index ? 50 : index + 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a card is playable
|
||||
const isPlayable = (index) => {
|
||||
return props.playableCards.includes(index)
|
||||
}
|
||||
</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>
|
||||
Reference in New Issue
Block a user