strat-gameplay-webapp/frontend-sba/components/Gameplay/GameplayPanel.vue
Cal Corum be31e2ccb4 CLAUDE: Complete in-game UI overhaul with player cards and outcome wizard
Features:
- PlayerCardModal: Tap any player to view full playing card image
- OutcomeWizard: Progressive 3-step outcome selection (On Base/Out/X-Check)
- GameBoard: Expandable view showing all 9 fielder positions
- Post-roll card display: Shows batter/pitcher card based on d6 roll
- CurrentSituation: Tappable player cards with modal integration

Bug fixes:
- Fix batter not advancing after play (state_manager recovery logic)
- Add dark mode support for buttons and panels (partial - iOS issue noted)

New files:
- PlayerCardModal.vue, OutcomeWizard.vue, BottomSheet.vue
- outcomeFlow.ts constants for outcome category mapping
- TEST_PLAN_UI_OVERHAUL.md with 23/24 tests passing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:23:38 -06:00

490 lines
12 KiB
Vue

<template>
<div class="gameplay-panel">
<div class="panel-container">
<!-- Panel Header -->
<div class="panel-header">
<h2 class="panel-title">Gameplay</h2>
<div class="panel-status">
<div :class="['status-indicator', statusClass]"/>
<span class="status-text">{{ statusText }}</span>
</div>
</div>
<!-- Workflow States -->
<div class="panel-content">
<!-- State: Idle (Waiting for decisions) -->
<div v-if="workflowState === 'idle'" class="state-idle">
<div class="state-message">
<svg class="w-12 h-12 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<div class="message-text">
Waiting for strategic decisions to complete...
</div>
</div>
</div>
<!-- State: Ready to Roll -->
<div v-else-if="workflowState === 'ready_to_roll'" class="state-ready">
<div v-if="!isMyTurn" class="state-message">
<svg class="w-12 h-12 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<div class="message-text">
Waiting for opponent to roll dice...
</div>
</div>
<div v-else class="state-content">
<div class="instruction-box">
<svg class="w-6 h-6 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<span>Your turn! Roll the dice to start the play.</span>
</div>
<DiceRoller
:can-roll="canRollDice"
:pending-roll="null"
@roll="handleRollDice"
/>
</div>
</div>
<!-- State: Rolled (Dice results shown, enter outcome) -->
<div v-else-if="workflowState === 'rolled'" class="state-rolled">
<DiceRoller
:can-roll="false"
:pending-roll="pendingRoll"
/>
<!-- Post-Roll Card Display -->
<div v-if="activeCardPlayer" class="post-roll-card">
<!-- Card Header -->
<div class="card-header">
<span class="card-label" :class="showBatterCard ? 'batter-label' : 'pitcher-label'">
{{ showBatterCard ? 'BATTER' : 'PITCHER' }} CARD
</span>
<span class="player-name">{{ activeCardPlayer.name }}</span>
</div>
<!-- Full-Width Card Image -->
<div class="card-image-wrapper">
<img
v-if="activeCardPlayer.image"
:src="activeCardPlayer.image"
:alt="`${activeCardPlayer.name} card`"
class="player-card-image"
>
<div v-else class="card-placeholder">
<span class="placeholder-initials">
{{ activeCardPlayer.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || '?' }}
</span>
</div>
</div>
</div>
<div class="divider"/>
<OutcomeWizard
:can-submit="canSubmitOutcome"
@submit="handleSubmitOutcome"
@cancel="handleCancelOutcome"
/>
</div>
<!-- State: Submitted (Processing) -->
<div v-else-if="workflowState === 'submitted'" class="state-submitted">
<div class="state-message">
<svg class="animate-spin w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<div class="message-text">
Processing play result...
</div>
</div>
</div>
<!-- State: Result (Play completed) -->
<div v-else-if="workflowState === 'result'" class="state-result">
<PlayResultDisplay
:result="lastPlayResult"
:auto-hide="false"
@dismiss="handleDismissResult"
/>
</div>
<!-- Error State -->
<div v-if="error" class="error-message">
<svg class="w-6 h-6 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<span>{{ error }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { RollData, PlayResult, PlayOutcome, SbaPlayer } from '~/types'
import DiceRoller from './DiceRoller.vue'
import OutcomeWizard from './OutcomeWizard.vue'
import PlayResultDisplay from './PlayResult.vue'
interface Props {
gameId: string
isMyTurn: boolean
canRollDice: boolean
pendingRoll: RollData | null
lastPlayResult: PlayResult | null
canSubmitOutcome: boolean
outs?: number
hasRunners?: boolean
// Player data for post-roll card display
batterPlayer?: SbaPlayer | null
pitcherPlayer?: SbaPlayer | null
}
const props = withDefaults(defineProps<Props>(), {
outs: 0,
hasRunners: false,
batterPlayer: null,
pitcherPlayer: null,
})
const emit = defineEmits<{
rollDice: []
submitOutcome: [{ outcome: PlayOutcome; hitLocation?: string }]
dismissResult: []
}>()
// Local state
const error = ref<string | null>(null)
const isSubmitting = ref(false)
// Post-roll card display: d6_one 1-3 = batter, 4-6 = pitcher
const showBatterCard = computed(() =>
props.pendingRoll && props.pendingRoll.d6_one <= 3
)
const activeCardPlayer = computed(() =>
showBatterCard.value ? props.batterPlayer : props.pitcherPlayer
)
// Workflow state computation
type WorkflowState = 'idle' | 'ready_to_roll' | 'rolled' | 'submitted' | 'result'
const workflowState = computed<WorkflowState>(() => {
// Show result if we have one
if (props.lastPlayResult) {
return 'result'
}
// Show submitted/processing state
if (isSubmitting.value) {
return 'submitted'
}
// Show dice results and outcome entry
if (props.pendingRoll) {
return 'rolled'
}
// Show roll button if can roll
if (props.canRollDice) {
return 'ready_to_roll'
}
// Default to idle
return 'idle'
})
// Status indicator
const statusClass = computed(() => {
if (error.value) return 'status-error'
if (workflowState.value === 'result') return 'status-success'
if (workflowState.value === 'submitted') return 'status-processing'
if (workflowState.value === 'rolled') return 'status-active'
if (workflowState.value === 'ready_to_roll' && props.isMyTurn) return 'status-active'
return 'status-idle'
})
const statusText = computed(() => {
if (error.value) return 'Error'
if (workflowState.value === 'result') return 'Play Complete'
if (workflowState.value === 'submitted') return 'Processing'
if (workflowState.value === 'rolled') return 'Enter Outcome'
if (workflowState.value === 'ready_to_roll') {
return props.isMyTurn ? 'Your Turn' : 'Opponent Turn'
}
return 'Waiting'
})
// Methods
const handleRollDice = () => {
error.value = null
emit('rollDice')
}
const handleSubmitOutcome = (payload: { outcome: PlayOutcome; hitLocation?: string }) => {
error.value = null
isSubmitting.value = true
emit('submitOutcome', payload)
// Reset submitting state after a delay (will be cleared by server response)
setTimeout(() => {
isSubmitting.value = false
}, 3000)
}
const handleCancelOutcome = () => {
error.value = null
// Could emit cancel event if needed
}
const handleDismissResult = () => {
error.value = null
emit('dismissResult')
}
</script>
<style scoped>
.gameplay-panel {
@apply w-full;
}
.panel-container {
@apply bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl shadow-lg;
@apply border border-gray-200;
}
/* Header */
.panel-header {
@apply flex justify-between items-center px-6 py-4 border-b border-gray-200;
@apply bg-white rounded-t-xl;
}
.panel-title {
@apply text-xl font-bold text-gray-900;
}
.panel-status {
@apply flex items-center gap-2;
}
.status-indicator {
@apply w-3 h-3 rounded-full;
}
.status-idle {
@apply bg-gray-400;
}
.status-active {
@apply bg-blue-500 animate-pulse;
}
.status-processing {
@apply bg-yellow-500 animate-pulse;
}
.status-success {
@apply bg-green-500;
}
.status-error {
@apply bg-red-500;
}
.status-text {
@apply text-sm font-medium text-gray-700;
}
/* Content */
.panel-content {
@apply p-6 space-y-4;
}
/* State Messages */
.state-message {
@apply flex flex-col items-center justify-center py-12 space-y-4;
}
.message-text {
@apply text-lg text-gray-600 text-center;
}
/* State: Ready */
.state-ready {
@apply space-y-4;
}
.state-content {
@apply space-y-4;
}
.instruction-box {
@apply flex items-center gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg;
}
.instruction-box span {
@apply text-sm font-medium text-blue-800;
}
/* State: Rolled */
.state-rolled {
@apply space-y-6;
}
/* Post-Roll Card Display */
.post-roll-card {
@apply bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-4;
@apply border-2 border-amber-200 shadow-md;
}
.card-header {
@apply flex items-center gap-3 mb-3;
}
.card-label {
@apply text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded;
}
.batter-label {
@apply bg-red-100 text-red-700;
}
.pitcher-label {
@apply bg-blue-100 text-blue-700;
}
.card-header .player-name {
@apply text-lg font-bold text-gray-900;
}
.card-image-wrapper {
@apply w-full rounded-lg overflow-hidden shadow-lg;
@apply ring-2 ring-amber-300 ring-offset-2;
}
.player-card-image {
@apply w-full h-auto object-contain;
}
.card-placeholder {
@apply w-full h-48 bg-gradient-to-br from-gray-300 to-gray-400;
@apply flex items-center justify-center;
}
.placeholder-initials {
@apply text-4xl font-bold text-gray-600;
}
.divider {
@apply border-t-2 border-gray-200;
}
/* State: Result */
.state-result {
@apply space-y-4;
}
/* Error Message */
.error-message {
@apply flex items-center gap-3 p-4 bg-red-50 border border-red-200 rounded-lg;
@apply text-red-800 font-medium;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.panel-container {
@apply from-gray-800 to-gray-900 border-gray-600;
@apply ring-1 ring-gray-700;
}
.panel-header {
@apply bg-gray-800 border-gray-600;
}
.panel-title {
@apply text-gray-100;
}
.status-text {
@apply text-gray-300;
}
.message-text {
@apply text-gray-400;
}
.instruction-box {
@apply bg-blue-900 bg-opacity-30 border-blue-700;
}
.instruction-box span {
@apply text-blue-300;
}
.divider {
@apply border-gray-700;
}
.error-message {
@apply bg-red-900 bg-opacity-30 border-red-700 text-red-300;
}
/* Post-roll card dark mode */
.post-roll-card {
@apply from-amber-900/30 to-orange-900/30 border-amber-700;
}
.card-header .player-name {
@apply text-gray-100;
}
.card-image-wrapper {
@apply ring-amber-600;
}
.card-placeholder {
@apply from-gray-600 to-gray-700;
}
.placeholder-initials {
@apply text-gray-300;
}
.batter-label {
@apply bg-red-900/50 text-red-300;
}
.pitcher-label {
@apply bg-blue-900/50 text-blue-300;
}
}
/* Mobile optimizations */
@media (max-width: 640px) {
.panel-header {
@apply px-4 py-3;
}
.panel-title {
@apply text-lg;
}
.panel-content {
@apply p-4;
}
.state-message {
@apply py-8;
}
.message-text {
@apply text-base;
}
}
</style>