feat: gameboard ui

This commit is contained in:
2025-12-29 11:15:02 +00:00
parent c68cda350e
commit cfda949ea6
59 changed files with 2564 additions and 18 deletions
+113
View File
@@ -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>
+116
View File
@@ -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>
+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>
+295
View File
@@ -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>
+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>
@@ -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>