Backend: - Add home_team_dice_color and away_team_dice_color to GameState model - Extract dice_color from game metadata in StateManager (default: cc0000) - Add runners_on_base param to roll_ab for chaos check skipping Frontend - Dice Display: - Create DiceShapes.vue with SVG d6 (square) and d20 (hexagon) shapes - Apply home team's dice_color to d6 dice, white for resolution d20 - Show chaos d20 in amber only when WP/PB check triggered - Add automatic text contrast based on color luminance - Reduce blank space and remove info bubble from dice results Frontend - Player Cards: - Consolidate pitcher/batter cards to single location below diamond - Add active card highlighting based on dice roll (d6_one: 1-3=batter, 4-6=pitcher) - New card header format: [Team] Position [Name] with full card image - Remove redundant card displays from GameBoard and GameplayPanel - Enlarge PlayerCardModal on desktop (max-w-3xl at 1024px+) Tests: - Add DiceShapes.spec.ts with 34 tests for color calculations and rendering - Update DiceRoller.spec.ts for new DiceShapes integration - Fix test_roll_dice_success for new runners_on_base parameter Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
366 lines
10 KiB
Vue
366 lines
10 KiB
Vue
<template>
|
|
<div class="current-situation">
|
|
<!-- Side-by-Side Card Layout -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<!-- Current Pitcher Card -->
|
|
<button
|
|
v-if="currentPitcher"
|
|
:class="[
|
|
'player-card pitcher-card card-transition',
|
|
pitcherCardClasses
|
|
]"
|
|
@click="openPlayerCard('pitcher')"
|
|
>
|
|
<!-- Card Header -->
|
|
<div class="card-header pitcher-header">
|
|
<span class="team-abbrev">{{ pitcherTeamAbbrev }}</span>
|
|
<span class="position-info">P</span>
|
|
<span class="player-name">{{ pitcherName }}</span>
|
|
</div>
|
|
|
|
<!-- Card Image -->
|
|
<div class="card-image-container">
|
|
<img
|
|
v-if="pitcherPlayer?.image"
|
|
:src="pitcherPlayer.image"
|
|
:alt="`${pitcherName} card`"
|
|
class="card-image"
|
|
@error="handleImageError"
|
|
>
|
|
<div v-else class="card-placeholder pitcher-placeholder">
|
|
<span class="placeholder-initials">{{ getPlayerFallbackInitial(pitcherPlayer) }}</span>
|
|
<span class="placeholder-label">No Card Image</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Current Batter Card -->
|
|
<button
|
|
v-if="currentBatter"
|
|
:class="[
|
|
'player-card batter-card card-transition',
|
|
batterCardClasses
|
|
]"
|
|
@click="openPlayerCard('batter')"
|
|
>
|
|
<!-- Card Header -->
|
|
<div class="card-header batter-header">
|
|
<span class="team-abbrev">{{ batterTeamAbbrev }}</span>
|
|
<span class="position-info">{{ currentBatter.batting_order }}. {{ currentBatter.position }}</span>
|
|
<span class="player-name">{{ batterName }}</span>
|
|
</div>
|
|
|
|
<!-- Card Image -->
|
|
<div class="card-image-container">
|
|
<img
|
|
v-if="batterPlayer?.image"
|
|
:src="batterPlayer.image"
|
|
:alt="`${batterName} card`"
|
|
class="card-image"
|
|
@error="handleImageError"
|
|
>
|
|
<div v-else class="card-placeholder batter-placeholder">
|
|
<span class="placeholder-initials">{{ getPlayerFallbackInitial(batterPlayer) }}</span>
|
|
<span class="placeholder-label">No Card Image</span>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
v-if="!currentBatter && !currentPitcher"
|
|
class="text-center py-12 px-4 bg-gray-50 dark:bg-gray-800 rounded-xl border-2 border-dashed border-gray-300 dark:border-gray-700"
|
|
>
|
|
<div class="w-16 h-16 mx-auto mb-4 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<p class="text-gray-500 dark:text-gray-400 font-medium">Waiting for game to start...</p>
|
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Players will appear here once the game begins.</p>
|
|
</div>
|
|
|
|
<!-- Player Card Modal -->
|
|
<PlayerCardModal
|
|
:is-open="isPlayerCardOpen"
|
|
:player="selectedPlayerData"
|
|
:position="selectedPlayerPosition"
|
|
:team-name="selectedPlayerTeam"
|
|
:show-substitute-button="false"
|
|
@close="closePlayerCard"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, watch, toRefs, ref } from 'vue'
|
|
import type { LineupPlayerState } from '~/types/game'
|
|
import { useGameStore } from '~/store/game'
|
|
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
|
|
|
|
interface Props {
|
|
currentBatter?: LineupPlayerState | null
|
|
currentPitcher?: LineupPlayerState | null
|
|
activeCard?: 'batter' | 'pitcher' | null // Which card to highlight (after dice roll)
|
|
batterTeamAbbrev?: string
|
|
pitcherTeamAbbrev?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
currentBatter: null,
|
|
currentPitcher: null,
|
|
activeCard: null,
|
|
batterTeamAbbrev: '',
|
|
pitcherTeamAbbrev: '',
|
|
})
|
|
|
|
// Debug: Watch for prop changes
|
|
const { currentBatter } = toRefs(props)
|
|
watch(currentBatter, (newBatter, oldBatter) => {
|
|
const oldInfo = oldBatter
|
|
? `lineup_id=${oldBatter.lineup_id}, batting_order=${oldBatter.batting_order}`
|
|
: 'None'
|
|
const newInfo = newBatter
|
|
? `lineup_id=${newBatter.lineup_id}, batting_order=${newBatter.batting_order}`
|
|
: 'None'
|
|
console.log('[CurrentSituation] currentBatter prop changed:', oldInfo, '->', newInfo)
|
|
}, { immediate: true })
|
|
|
|
const gameStore = useGameStore()
|
|
|
|
// Resolve player data from lineup using lineup_id
|
|
const batterPlayer = computed(() => {
|
|
if (!props.currentBatter) return null
|
|
const lineupEntry = gameStore.findPlayerInLineup(props.currentBatter.lineup_id)
|
|
return lineupEntry?.player ?? null
|
|
})
|
|
|
|
const pitcherPlayer = computed(() => {
|
|
if (!props.currentPitcher) return null
|
|
const lineupEntry = gameStore.findPlayerInLineup(props.currentPitcher.lineup_id)
|
|
return lineupEntry?.player ?? null
|
|
})
|
|
|
|
// Computed properties for player names with fallback
|
|
const batterName = computed(() => {
|
|
if (batterPlayer.value?.name) return batterPlayer.value.name
|
|
if (!props.currentBatter) return 'Unknown Batter'
|
|
return `Player #${props.currentBatter.card_id || props.currentBatter.lineup_id}`
|
|
})
|
|
|
|
const pitcherName = computed(() => {
|
|
if (pitcherPlayer.value?.name) return pitcherPlayer.value.name
|
|
if (!props.currentPitcher) return 'Unknown Pitcher'
|
|
return `Player #${props.currentPitcher.card_id || props.currentPitcher.lineup_id}`
|
|
})
|
|
|
|
// Get player avatar fallback - use first + last initials (e.g., "Alex Verdugo" -> "AV")
|
|
// Ignores common suffixes like Jr, Sr, II, III, IV
|
|
function getPlayerFallbackInitial(player: { name: string } | null): string {
|
|
if (!player) return '?'
|
|
const suffixes = ['jr', 'jr.', 'sr', 'sr.', 'ii', 'iii', 'iv', 'v']
|
|
const parts = player.name.trim().split(/\s+/).filter(
|
|
part => !suffixes.includes(part.toLowerCase())
|
|
)
|
|
if (parts.length === 0) return '?'
|
|
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
|
|
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
|
}
|
|
|
|
// Handle image loading errors
|
|
function handleImageError(e: Event) {
|
|
const img = e.target as HTMLImageElement
|
|
img.style.display = 'none'
|
|
// Show sibling placeholder
|
|
const placeholder = img.nextElementSibling
|
|
if (placeholder) {
|
|
(placeholder as HTMLElement).style.display = 'flex'
|
|
}
|
|
}
|
|
|
|
// Player card modal state
|
|
const isPlayerCardOpen = ref(false)
|
|
const selectedPlayerData = ref<{
|
|
id: number
|
|
name: string
|
|
image: string
|
|
headshot?: string
|
|
} | null>(null)
|
|
const selectedPlayerPosition = ref('')
|
|
const selectedPlayerTeam = ref('')
|
|
|
|
// Card highlight state based on activeCard prop
|
|
const isPitcherActive = computed(() => props.activeCard === 'pitcher')
|
|
const isBatterActive = computed(() => props.activeCard === 'batter')
|
|
const hasActiveCard = computed(() => props.activeCard !== null)
|
|
|
|
// Dynamic classes for pitcher card
|
|
const pitcherCardClasses = computed(() => ({
|
|
'card-active': isPitcherActive.value,
|
|
'card-inactive': hasActiveCard.value && !isPitcherActive.value,
|
|
}))
|
|
|
|
// Dynamic classes for batter card
|
|
const batterCardClasses = computed(() => ({
|
|
'card-active': isBatterActive.value,
|
|
'card-inactive': hasActiveCard.value && !isBatterActive.value,
|
|
}))
|
|
|
|
// Open player card modal for batter or pitcher
|
|
function openPlayerCard(type: 'batter' | 'pitcher') {
|
|
const player = type === 'batter' ? batterPlayer.value : pitcherPlayer.value
|
|
const state = type === 'batter' ? props.currentBatter : props.currentPitcher
|
|
|
|
if (!player) return
|
|
|
|
selectedPlayerData.value = {
|
|
id: player.id,
|
|
name: player.name,
|
|
image: player.image || '',
|
|
headshot: player.headshot || undefined
|
|
}
|
|
selectedPlayerPosition.value = state?.position || ''
|
|
selectedPlayerTeam.value = type === 'batter' ? props.batterTeamAbbrev : props.pitcherTeamAbbrev
|
|
isPlayerCardOpen.value = true
|
|
}
|
|
|
|
function closePlayerCard() {
|
|
isPlayerCardOpen.value = false
|
|
selectedPlayerData.value = null
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Card Container */
|
|
.player-card {
|
|
@apply rounded-xl overflow-hidden cursor-pointer;
|
|
@apply border-2 shadow-lg;
|
|
@apply flex flex-col;
|
|
}
|
|
|
|
.pitcher-card {
|
|
@apply bg-gradient-to-b from-blue-900 to-blue-950 border-blue-600;
|
|
}
|
|
|
|
.batter-card {
|
|
@apply bg-gradient-to-b from-red-900 to-red-950 border-red-600;
|
|
}
|
|
|
|
/* Card Header */
|
|
.card-header {
|
|
@apply px-3 py-2 flex items-center gap-2 text-white;
|
|
@apply text-sm font-semibold;
|
|
}
|
|
|
|
.pitcher-header {
|
|
@apply bg-blue-800/80;
|
|
}
|
|
|
|
.batter-header {
|
|
@apply bg-red-800/80;
|
|
}
|
|
|
|
.team-abbrev {
|
|
@apply font-bold text-white/90;
|
|
}
|
|
|
|
.position-info {
|
|
@apply text-white/70;
|
|
}
|
|
|
|
.player-name {
|
|
@apply truncate flex-1 text-right font-bold;
|
|
}
|
|
|
|
/* Card Image Container */
|
|
.card-image-container {
|
|
@apply relative w-full;
|
|
}
|
|
|
|
.card-image {
|
|
@apply w-full h-auto object-contain;
|
|
}
|
|
|
|
/* Placeholder when no image */
|
|
.card-placeholder {
|
|
@apply w-full flex flex-col items-center justify-center;
|
|
@apply py-12;
|
|
}
|
|
|
|
.pitcher-placeholder {
|
|
@apply bg-gradient-to-br from-blue-700 to-blue-900;
|
|
}
|
|
|
|
.batter-placeholder {
|
|
@apply bg-gradient-to-br from-red-700 to-red-900;
|
|
}
|
|
|
|
.placeholder-initials {
|
|
@apply text-5xl font-bold text-white/60;
|
|
}
|
|
|
|
.placeholder-label {
|
|
@apply text-sm text-white/40 mt-2;
|
|
}
|
|
|
|
/* Card highlight transition */
|
|
.card-transition {
|
|
@apply transition-all duration-300 ease-in-out;
|
|
}
|
|
|
|
/* Active card styling - emphasized */
|
|
.card-active {
|
|
@apply scale-105 z-10;
|
|
animation: pulseGlow 2s ease-in-out infinite;
|
|
}
|
|
|
|
/* Inactive card styling - dimmed */
|
|
.card-inactive {
|
|
@apply opacity-50 scale-95;
|
|
}
|
|
|
|
/* Pulsing glow for active pitcher card */
|
|
.pitcher-card.card-active {
|
|
animation: pulseGlowBlue 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulseGlowBlue {
|
|
0%, 100% {
|
|
box-shadow: 0 0 15px 2px rgba(59, 130, 246, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 30px 8px rgba(59, 130, 246, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
}
|
|
|
|
/* Pulsing glow for active batter card */
|
|
.batter-card.card-active {
|
|
animation: pulseGlowRed 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes pulseGlowRed {
|
|
0%, 100% {
|
|
box-shadow: 0 0 15px 2px rgba(239, 68, 68, 0.5), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 30px 8px rgba(239, 68, 68, 0.7), 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
|
}
|
|
}
|
|
|
|
/* Responsive: Smaller cards on mobile */
|
|
@media (max-width: 640px) {
|
|
.card-header {
|
|
@apply px-2 py-1.5 text-xs;
|
|
}
|
|
|
|
.placeholder-initials {
|
|
@apply text-3xl;
|
|
}
|
|
|
|
.placeholder-label {
|
|
@apply text-xs;
|
|
}
|
|
}
|
|
</style>
|