strat-gameplay-webapp/frontend-sba/components/Game/GameBoard.vue
Cal Corum 2b8fea36a8 CLAUDE: Redesign dice display with team colors and consolidate player cards
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>
2026-01-24 00:16:32 -06:00

414 lines
16 KiB
Vue

<template>
<div class="game-board-container">
<!-- Baseball Diamond Visualization -->
<div class="relative w-full max-w-md mx-auto aspect-square">
<!-- Field Background (Green) -->
<div class="absolute inset-0 bg-gradient-to-br from-green-600 to-green-700 rounded-lg overflow-hidden">
<!-- Infield Dirt (Diamond Shape) -->
<div class="absolute inset-0 flex items-center justify-center">
<div class="relative w-3/4 h-3/4">
<div class="absolute inset-0 rotate-45 bg-gradient-to-br from-amber-700 to-amber-800 rounded-lg opacity-80"/>
</div>
</div>
<!-- Base Paths (White Lines) -->
<svg class="absolute inset-0 w-full h-full" viewBox="0 0 100 100">
<!-- 1st to 2nd base line -->
<line x1="72" y1="50" x2="50" y2="28" stroke="white" stroke-width="0.3" opacity="0.6" />
<!-- 2nd to 3rd base line -->
<line x1="50" y1="28" x2="28" y2="50" stroke="white" stroke-width="0.3" opacity="0.6" />
<!-- 3rd to home line -->
<line x1="28" y1="50" x2="50" y2="72" stroke="white" stroke-width="0.3" opacity="0.6" />
<!-- Home to 1st line -->
<line x1="50" y1="72" x2="72" y2="50" stroke="white" stroke-width="0.3" opacity="0.6" />
</svg>
<!-- Pitcher's Mound -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div class="w-10 h-10 bg-amber-700 rounded-full border-2 border-amber-600 shadow-lg flex items-center justify-center">
<div class="w-6 h-6 bg-white/20 rounded-full"/>
</div>
</div>
<!-- Current Pitcher (on mound) -->
<button
v-if="currentPitcher"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mt-12 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 rounded-lg"
@click="openPlayerCard('pitcher')"
>
<div class="text-center">
<div class="w-8 h-8 mx-auto bg-blue-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold">
P
</div>
<div class="mt-1 text-xs font-semibold text-white bg-black/30 backdrop-blur px-2 py-0.5 rounded-full whitespace-nowrap">
{{ getPitcherName }}
</div>
</div>
</button>
<!-- Home Plate -->
<div class="absolute bottom-[14%] left-1/2 -translate-x-1/2">
<div class="relative">
<!-- Home Plate (Pentagon Shape) -->
<div class="w-8 h-8 bg-white rotate-45 shadow-xl border-2 border-gray-200"/>
<!-- Current Batter -->
<button
v-if="currentBatter"
class="absolute -bottom-14 left-1/2 -translate-x-1/2 w-32 cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2 rounded-lg"
@click="openPlayerCard('batter')"
>
<div class="text-center">
<div class="w-8 h-8 mx-auto bg-red-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white text-xs font-bold mb-1">
B
</div>
<div class="text-xs font-semibold text-white bg-black/40 backdrop-blur px-2 py-1 rounded-lg">
{{ getBatterName }}
</div>
<div class="text-[10px] text-white/80 mt-0.5">
Batting {{ currentBatter.batting_order }}
</div>
</div>
</button>
</div>
</div>
<!-- 1st Base -->
<div class="absolute top-1/2 right-[14%] -translate-y-1/2">
<div
class="w-10 h-10 rotate-45 shadow-xl transition-all duration-300"
:class="runners.first ? 'bg-yellow-400 border-2 border-yellow-300 animate-pulse-subtle' : 'bg-white/90 border-2 border-gray-200'"
>
<!-- Runner on 1st -->
<div
v-if="runners.first"
class="absolute -top-8 left-1/2 -translate-x-1/2 -rotate-45"
>
<div class="w-6 h-6 bg-yellow-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center">
<span class="text-white text-[10px] font-bold">1</span>
</div>
</div>
</div>
<div class="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-white/60 whitespace-nowrap">
1ST
</div>
</div>
<!-- 2nd Base -->
<div class="absolute top-[14%] left-1/2 -translate-x-1/2">
<div
class="w-10 h-10 rotate-45 shadow-xl transition-all duration-300"
:class="runners.second ? 'bg-yellow-400 border-2 border-yellow-300 animate-pulse-subtle' : 'bg-white/90 border-2 border-gray-200'"
>
<!-- Runner on 2nd -->
<div
v-if="runners.second"
class="absolute -top-8 left-1/2 -translate-x-1/2 -rotate-45"
>
<div class="w-6 h-6 bg-yellow-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center">
<span class="text-white text-[10px] font-bold">2</span>
</div>
</div>
</div>
<div class="absolute -top-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-white/60 whitespace-nowrap">
2ND
</div>
</div>
<!-- 3rd Base -->
<div class="absolute top-1/2 left-[14%] -translate-y-1/2">
<div
class="w-10 h-10 rotate-45 shadow-xl transition-all duration-300"
:class="runners.third ? 'bg-yellow-400 border-2 border-yellow-300 animate-pulse-subtle' : 'bg-white/90 border-2 border-gray-200'"
>
<!-- Runner on 3rd -->
<div
v-if="runners.third"
class="absolute -top-8 left-1/2 -translate-x-1/2 -rotate-45"
>
<div class="w-6 h-6 bg-yellow-500 rounded-full border-2 border-white shadow-lg flex items-center justify-center">
<span class="text-white text-[10px] font-bold">3</span>
</div>
</div>
</div>
<div class="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[10px] font-bold text-white/60 whitespace-nowrap">
3RD
</div>
</div>
<!-- Outfield Grass Pattern (Subtle) -->
<div class="absolute inset-0 opacity-10 pointer-events-none">
<div class="absolute inset-0" style="background: repeating-linear-gradient(90deg, transparent 0px, transparent 20px, rgba(0,0,0,0.05) 20px, rgba(0,0,0,0.05) 40px)"/>
</div>
<!-- Expanded View: All 9 Fielder Positions -->
<template v-if="isExpanded">
<!-- Catcher (behind home) -->
<button
class="absolute bottom-[6%] left-1/2 -translate-x-1/2 fielder-button"
:class="{ 'fielder-active': getFielderInfo('C').exists }"
:title="getFielderInfo('C').name || 'Catcher'"
@click="openFielderCard('C')"
>
<span class="fielder-label">{{ getFielderInfo('C').initials }}</span>
</button>
<!-- First Baseman -->
<button
class="absolute top-[45%] right-[20%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('1B').exists }"
:title="getFielderInfo('1B').name || 'First Base'"
@click="openFielderCard('1B')"
>
<span class="fielder-label">{{ getFielderInfo('1B').initials }}</span>
</button>
<!-- Second Baseman -->
<button
class="absolute top-[35%] right-[35%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('2B').exists }"
:title="getFielderInfo('2B').name || 'Second Base'"
@click="openFielderCard('2B')"
>
<span class="fielder-label">{{ getFielderInfo('2B').initials }}</span>
</button>
<!-- Shortstop -->
<button
class="absolute top-[35%] left-[35%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('SS').exists }"
:title="getFielderInfo('SS').name || 'Shortstop'"
@click="openFielderCard('SS')"
>
<span class="fielder-label">{{ getFielderInfo('SS').initials }}</span>
</button>
<!-- Third Baseman -->
<button
class="absolute top-[45%] left-[20%] fielder-button"
:class="{ 'fielder-active': getFielderInfo('3B').exists }"
:title="getFielderInfo('3B').name || 'Third Base'"
@click="openFielderCard('3B')"
>
<span class="fielder-label">{{ getFielderInfo('3B').initials }}</span>
</button>
<!-- Left Fielder -->
<button
class="absolute top-[15%] left-[15%] fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('LF').exists }"
:title="getFielderInfo('LF').name || 'Left Field'"
@click="openFielderCard('LF')"
>
<span class="fielder-label">{{ getFielderInfo('LF').initials }}</span>
</button>
<!-- Center Fielder -->
<button
class="absolute top-[8%] left-1/2 -translate-x-1/2 fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('CF').exists }"
:title="getFielderInfo('CF').name || 'Center Field'"
@click="openFielderCard('CF')"
>
<span class="fielder-label">{{ getFielderInfo('CF').initials }}</span>
</button>
<!-- Right Fielder -->
<button
class="absolute top-[15%] right-[15%] fielder-button fielder-outfield"
:class="{ 'fielder-active': getFielderInfo('RF').exists }"
:title="getFielderInfo('RF').name || 'Right Field'"
@click="openFielderCard('RF')"
>
<span class="fielder-label">{{ getFielderInfo('RF').initials }}</span>
</button>
</template>
<!-- Expand/Collapse Button -->
<button
class="absolute bottom-2 right-2 w-8 h-8 bg-white/90 hover:bg-white rounded-full shadow-lg flex items-center justify-center text-gray-700 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-400"
@click="toggleExpanded"
:title="isExpanded ? 'Collapse field view' : 'Expand to see all fielders'"
>
<svg v-if="!isExpanded" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Player Card Modal -->
<PlayerCardModal
:is-open="isPlayerCardOpen"
:player="selectedPlayerData"
:position="selectedPlayerPosition"
:show-substitute-button="false"
@close="closePlayerCard"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { LineupPlayerState } from '~/types/game'
import type { Lineup } from '~/types/player'
import { useGameStore } from '~/store/game'
import PlayerCardModal from '~/components/Player/PlayerCardModal.vue'
interface Props {
runners?: {
first: boolean
second: boolean
third: boolean
}
currentBatter?: LineupPlayerState | null
currentPitcher?: LineupPlayerState | null
fieldingLineup?: Lineup[]
}
const props = withDefaults(defineProps<Props>(), {
runners: () => ({ first: false, second: false, third: false }),
currentBatter: null,
currentPitcher: null,
fieldingLineup: () => []
})
const gameStore = useGameStore()
// UI State
const isExpanded = ref(false)
const isPlayerCardOpen = ref(false)
const selectedPlayerData = ref<{
id: number
name: string
image: string
headshot?: string
} | null>(null)
const selectedPlayerPosition = ref('')
// 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
})
// Helper to get player name with fallback
const getBatterName = computed(() => batterPlayer.value?.name ?? `Player #${props.currentBatter?.lineup_id}`)
const getPitcherName = computed(() => pitcherPlayer.value?.name ?? `Player #${props.currentPitcher?.lineup_id}`)
// Toggle expanded view
function toggleExpanded() {
isExpanded.value = !isExpanded.value
}
// Open player card modal
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 || (type === 'pitcher' ? 'P' : '')
isPlayerCardOpen.value = true
}
function closePlayerCard() {
isPlayerCardOpen.value = false
selectedPlayerData.value = null
}
// Get fielder by position from lineup
function getFielderByPosition(position: string): Lineup | null {
return props.fieldingLineup.find(p => p.position === position) || null
}
// Open fielder card modal
function openFielderCard(position: string) {
const fielder = getFielderByPosition(position)
if (!fielder) return
selectedPlayerData.value = {
id: fielder.player.id,
name: fielder.player.name,
image: fielder.player.image || '',
headshot: fielder.player.headshot || undefined
}
selectedPlayerPosition.value = position
isPlayerCardOpen.value = true
}
// Get fielder display info (name initials and whether they exist)
function getFielderInfo(position: string): { initials: string; name: string; exists: boolean } {
const fielder = getFielderByPosition(position)
if (!fielder) {
return { initials: position, name: '', exists: false }
}
const nameParts = fielder.player.name.split(' ')
const initials = nameParts.length >= 2
? `${nameParts[0][0]}${nameParts[nameParts.length - 1][0]}`
: nameParts[0].substring(0, 2)
return { initials: initials.toUpperCase(), name: fielder.player.name, exists: true }
}
</script>
<style scoped>
/* Subtle pulse for runners on base */
@keyframes pulse-subtle {
0%, 100% {
transform: scale(1) rotate(45deg);
opacity: 1;
}
50% {
transform: scale(1.05) rotate(45deg);
opacity: 0.9;
}
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* Fielder buttons in expanded view */
.fielder-button {
@apply w-7 h-7 rounded-full border-2 border-white shadow-lg;
@apply flex items-center justify-center;
@apply bg-gray-500 text-white;
@apply cursor-pointer transition-all duration-200;
@apply hover:scale-110 hover:bg-gray-600;
@apply focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1;
}
.fielder-button.fielder-active {
@apply bg-blue-600 hover:bg-blue-700;
}
.fielder-button.fielder-outfield {
@apply bg-green-600;
}
.fielder-button.fielder-outfield.fielder-active {
@apply bg-emerald-600 hover:bg-emerald-700;
}
.fielder-label {
@apply text-[9px] font-bold leading-none;
}
</style>