CLAUDE: Add XCheckWizard component and result constants (step 6)
New files: - constants/xCheckResults.ts - Labels, helpers for all result codes - components/Gameplay/XCheckWizard.vue - Interactive x-check UI XCheckWizard features: ✅ Displays d20 and 3d6 dice results prominently ✅ Shows 5-column chart row (Range 1-5) as selectable buttons ✅ Hash result sub-choices (G2#/G3# → pick G2 or SI2) ✅ SPD result sub-choice (click to reveal d20, pick safe/out) ✅ Error selection (NO/E1/E2/E3/RP) based on 3d6 ✅ Submit validation (both result + error required) ✅ Read-only mode for transparency (opponent sees same UI) ✅ Mobile-responsive layout (stacks on small screens) ✅ Tailwind styling with clear visual hierarchy Helper functions: - getResultLabel() - Display names for all codes - getErrorLabel() - Display names for error types - isHashResult() - Detect G2#/G3# - isSpdResult() - Detect SPD - getHashConversions() - Get conversion options Next: Integrate XCheckWizard into GameplayPanel Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
160550afca
commit
f77666db87
506
frontend-sba/components/Gameplay/XCheckWizard.vue
Normal file
506
frontend-sba/components/Gameplay/XCheckWizard.vue
Normal file
@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="x-check-wizard" :class="{ 'read-only': readonly }">
|
||||
<!-- Header -->
|
||||
<div class="wizard-header">
|
||||
<h3 class="wizard-title">
|
||||
X-Check: {{ xCheckData.position }} ({{ getPositionName(xCheckData.position) }})
|
||||
</h3>
|
||||
<p v-if="readonly" class="waiting-message">
|
||||
Waiting for opponent to select result...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dice Display -->
|
||||
<div class="dice-display">
|
||||
<div class="dice-group">
|
||||
<div class="dice-label">Range Roll (d20)</div>
|
||||
<div class="dice-value d20">{{ xCheckData.d20_roll }}</div>
|
||||
</div>
|
||||
<div class="dice-group">
|
||||
<div class="dice-label">Error Roll (3d6)</div>
|
||||
<div class="dice-value d6">
|
||||
{{ xCheckData.d6_total }}
|
||||
<span class="dice-breakdown">({{ xCheckData.d6_individual.join(' + ') }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPD d20 (click to reveal if present) -->
|
||||
<div v-if="xCheckData.spd_d20 !== null" class="spd-container">
|
||||
<button
|
||||
v-if="!spdRevealed"
|
||||
class="spd-reveal-button"
|
||||
:disabled="readonly"
|
||||
@click="spdRevealed = true"
|
||||
>
|
||||
<svg 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Tap to Reveal SPD d20
|
||||
</button>
|
||||
<div v-else class="spd-revealed">
|
||||
<div class="dice-label">Speed Check d20</div>
|
||||
<div class="dice-value d20">{{ xCheckData.spd_d20 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Defensive Positioning Note -->
|
||||
<div v-if="defensivePositioningNote" class="positioning-note">
|
||||
<svg 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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ defensivePositioningNote }}
|
||||
</div>
|
||||
|
||||
<!-- Chart Row Selection -->
|
||||
<div class="chart-selection">
|
||||
<h4 class="selection-title">Select Range Result</h4>
|
||||
<div class="chart-row">
|
||||
<button
|
||||
v-for="(resultCode, index) in xCheckData.chart_row"
|
||||
:key="index"
|
||||
class="chart-column"
|
||||
:class="{
|
||||
selected: selectedColumn === index && !showHashChoice && !showSpdChoice,
|
||||
disabled: readonly,
|
||||
}"
|
||||
:disabled="readonly"
|
||||
@click="selectColumn(index, resultCode)"
|
||||
>
|
||||
<div class="column-header">Range {{ index + 1 }}</div>
|
||||
<div class="column-result">{{ resultCode }}</div>
|
||||
<div class="column-label">{{ getResultLabel(resultCode) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hash Result Sub-Choice (G2#/G3# → G2 or SI2) -->
|
||||
<div v-if="showHashChoice" class="sub-choice">
|
||||
<h4 class="sub-choice-title">Speed Test Result</h4>
|
||||
<p class="sub-choice-description">
|
||||
Check batter's speed rating on their card. Did they pass the speed test?
|
||||
</p>
|
||||
<div class="sub-choice-buttons">
|
||||
<button
|
||||
v-for="option in hashOptions"
|
||||
:key="option"
|
||||
class="sub-choice-button"
|
||||
:class="{ selected: selectedResult === option }"
|
||||
:disabled="readonly"
|
||||
@click="selectHashResult(option)"
|
||||
>
|
||||
<div class="option-code">{{ option }}</div>
|
||||
<div class="option-label">{{ getResultLabel(option) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPD Result Sub-Choice -->
|
||||
<div v-if="showSpdChoice" class="sub-choice">
|
||||
<h4 class="sub-choice-title">Speed Check</h4>
|
||||
<p class="sub-choice-description">
|
||||
Check batter's speed rating vs. the d20 roll. Safe or out?
|
||||
</p>
|
||||
<div class="sub-choice-buttons">
|
||||
<button
|
||||
class="sub-choice-button"
|
||||
:class="{ selected: selectedResult === 'G3' }"
|
||||
:disabled="readonly"
|
||||
@click="selectSpdResult('G3')"
|
||||
>
|
||||
<div class="option-code">G3</div>
|
||||
<div class="option-label">Out (failed check)</div>
|
||||
</button>
|
||||
<button
|
||||
class="sub-choice-button"
|
||||
:class="{ selected: selectedResult === 'SI1' }"
|
||||
:disabled="readonly"
|
||||
@click="selectSpdResult('SI1')"
|
||||
>
|
||||
<div class="option-code">SI1</div>
|
||||
<div class="option-label">Safe (passed check)</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Selection -->
|
||||
<div class="error-selection">
|
||||
<h4 class="selection-title">Select Error Result</h4>
|
||||
<p class="selection-description">
|
||||
Check defender's error rating on their card based on 3d6 total ({{ xCheckData.d6_total }})
|
||||
</p>
|
||||
<div class="error-row">
|
||||
<button
|
||||
v-for="errorCode in ERROR_OPTIONS"
|
||||
:key="errorCode"
|
||||
class="error-button"
|
||||
:class="{
|
||||
selected: selectedError === errorCode,
|
||||
disabled: readonly,
|
||||
'no-error': errorCode === 'NO',
|
||||
}"
|
||||
:disabled="readonly"
|
||||
@click="selectError(errorCode)"
|
||||
>
|
||||
<div class="error-code">{{ errorCode }}</div>
|
||||
<div class="error-label">{{ getErrorLabel(errorCode) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="wizard-actions">
|
||||
<button
|
||||
class="submit-button"
|
||||
:disabled="!canSubmit || readonly"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<svg v-if="!readonly" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ readonly ? 'Waiting...' : 'Submit Result' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { XCheckData } from '~/types/game'
|
||||
import {
|
||||
getResultLabel,
|
||||
getErrorLabel,
|
||||
isHashResult,
|
||||
isSpdResult,
|
||||
getHashConversions,
|
||||
} from '~/constants/xCheckResults'
|
||||
|
||||
interface Props {
|
||||
xCheckData: XCheckData
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [payload: { resultCode: string; errorResult: string }]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const selectedColumn = ref<number | null>(null)
|
||||
const selectedResult = ref<string | null>(null)
|
||||
const selectedError = ref<string>('NO') // Default to no error
|
||||
const showHashChoice = ref(false)
|
||||
const showSpdChoice = ref(false)
|
||||
const hashOptions = ref<string[]>([])
|
||||
const spdRevealed = ref(false)
|
||||
|
||||
const ERROR_OPTIONS = ['NO', 'E1', 'E2', 'E3', 'RP']
|
||||
|
||||
// Position name mapping
|
||||
const POSITION_NAMES: Record<string, string> = {
|
||||
P: 'Pitcher',
|
||||
C: 'Catcher',
|
||||
'1B': 'First Base',
|
||||
'2B': 'Second Base',
|
||||
'3B': 'Third Base',
|
||||
SS: 'Shortstop',
|
||||
LF: 'Left Field',
|
||||
CF: 'Center Field',
|
||||
RF: 'Right Field',
|
||||
}
|
||||
|
||||
function getPositionName(position: string): string {
|
||||
return POSITION_NAMES[position] || position
|
||||
}
|
||||
|
||||
// Defensive positioning note (if applicable)
|
||||
const defensivePositioningNote = computed(() => {
|
||||
// TODO: Get from game state - check if playing in affects range
|
||||
// For now, just return null
|
||||
return null
|
||||
})
|
||||
|
||||
// Selection handlers
|
||||
function selectColumn(index: number, resultCode: string) {
|
||||
if (props.readonly) return
|
||||
|
||||
selectedColumn.value = index
|
||||
|
||||
// Check if this is a hash result (G2#/G3#)
|
||||
if (isHashResult(resultCode)) {
|
||||
const conversions = getHashConversions(resultCode)
|
||||
if (conversions) {
|
||||
showHashChoice.value = true
|
||||
showSpdChoice.value = false
|
||||
hashOptions.value = conversions
|
||||
selectedResult.value = null // Reset until player picks
|
||||
}
|
||||
}
|
||||
// Check if this is SPD
|
||||
else if (isSpdResult(resultCode)) {
|
||||
showSpdChoice.value = true
|
||||
showHashChoice.value = false
|
||||
selectedResult.value = null // Reset until player picks
|
||||
}
|
||||
// Simple result - select immediately
|
||||
else {
|
||||
showHashChoice.value = false
|
||||
showSpdChoice.value = false
|
||||
selectedResult.value = resultCode
|
||||
}
|
||||
}
|
||||
|
||||
function selectHashResult(option: string) {
|
||||
if (props.readonly) return
|
||||
selectedResult.value = option
|
||||
showHashChoice.value = false
|
||||
}
|
||||
|
||||
function selectSpdResult(option: string) {
|
||||
if (props.readonly) return
|
||||
selectedResult.value = option
|
||||
showSpdChoice.value = false
|
||||
}
|
||||
|
||||
function selectError(errorCode: string) {
|
||||
if (props.readonly) return
|
||||
selectedError.value = errorCode
|
||||
}
|
||||
|
||||
// Can submit when both result and error are selected
|
||||
const canSubmit = computed(() => {
|
||||
return selectedResult.value !== null && selectedError.value !== null
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
if (!canSubmit.value || props.readonly) return
|
||||
|
||||
emit('submit', {
|
||||
resultCode: selectedResult.value!,
|
||||
errorResult: selectedError.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.x-check-wizard {
|
||||
@apply bg-white rounded-lg shadow-lg p-6 space-y-6 max-w-4xl mx-auto;
|
||||
}
|
||||
|
||||
.x-check-wizard.read-only {
|
||||
@apply opacity-75;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.wizard-header {
|
||||
@apply text-center pb-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.wizard-title {
|
||||
@apply text-2xl font-bold text-gray-900;
|
||||
}
|
||||
|
||||
.waiting-message {
|
||||
@apply mt-2 text-sm text-orange-600 font-medium;
|
||||
}
|
||||
|
||||
/* Dice Display */
|
||||
.dice-display {
|
||||
@apply flex justify-center gap-8;
|
||||
}
|
||||
|
||||
.dice-group {
|
||||
@apply text-center;
|
||||
}
|
||||
|
||||
.dice-label {
|
||||
@apply text-sm font-medium text-gray-600 mb-2;
|
||||
}
|
||||
|
||||
.dice-value {
|
||||
@apply text-4xl font-bold rounded-lg px-6 py-3 shadow-md;
|
||||
}
|
||||
|
||||
.dice-value.d20 {
|
||||
@apply bg-blue-100 text-blue-900;
|
||||
}
|
||||
|
||||
.dice-value.d6 {
|
||||
@apply bg-green-100 text-green-900;
|
||||
}
|
||||
|
||||
.dice-breakdown {
|
||||
@apply text-lg text-gray-600 ml-2;
|
||||
}
|
||||
|
||||
/* SPD Container */
|
||||
.spd-container {
|
||||
@apply text-center py-4 bg-yellow-50 rounded-lg border-2 border-yellow-200;
|
||||
}
|
||||
|
||||
.spd-reveal-button {
|
||||
@apply inline-flex items-center gap-2 px-6 py-3 bg-yellow-500 text-white font-semibold rounded-lg hover:bg-yellow-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.spd-revealed {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
/* Positioning Note */
|
||||
.positioning-note {
|
||||
@apply flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-800 rounded-lg text-sm;
|
||||
}
|
||||
|
||||
/* Chart Selection */
|
||||
.chart-selection {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.selection-title {
|
||||
@apply text-lg font-semibold text-gray-900;
|
||||
}
|
||||
|
||||
.selection-description {
|
||||
@apply text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
@apply grid grid-cols-5 gap-3;
|
||||
}
|
||||
|
||||
.chart-column {
|
||||
@apply flex flex-col items-center p-4 border-2 border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all cursor-pointer disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.chart-column.selected {
|
||||
@apply border-blue-600 bg-blue-100 ring-2 ring-blue-500;
|
||||
}
|
||||
|
||||
.chart-column.disabled {
|
||||
@apply hover:border-gray-300 hover:bg-white;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
@apply text-xs font-medium text-gray-500 mb-1;
|
||||
}
|
||||
|
||||
.column-result {
|
||||
@apply text-2xl font-bold text-gray-900 mb-1;
|
||||
}
|
||||
|
||||
.column-label {
|
||||
@apply text-xs text-gray-600 text-center;
|
||||
}
|
||||
|
||||
/* Sub-Choice */
|
||||
.sub-choice {
|
||||
@apply p-6 bg-gradient-to-br from-purple-50 to-blue-50 rounded-lg border-2 border-purple-300 space-y-4;
|
||||
}
|
||||
|
||||
.sub-choice-title {
|
||||
@apply text-lg font-bold text-purple-900;
|
||||
}
|
||||
|
||||
.sub-choice-description {
|
||||
@apply text-sm text-gray-700;
|
||||
}
|
||||
|
||||
.sub-choice-buttons {
|
||||
@apply flex gap-4 justify-center;
|
||||
}
|
||||
|
||||
.sub-choice-button {
|
||||
@apply flex flex-col items-center p-6 border-2 border-purple-300 rounded-lg hover:border-purple-600 hover:bg-purple-100 transition-all cursor-pointer min-w-[150px] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.sub-choice-button.selected {
|
||||
@apply border-purple-700 bg-purple-200 ring-2 ring-purple-600;
|
||||
}
|
||||
|
||||
.option-code {
|
||||
@apply text-3xl font-bold text-purple-900 mb-2;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
@apply text-sm text-gray-700 text-center;
|
||||
}
|
||||
|
||||
/* Error Selection */
|
||||
.error-selection {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
@apply flex gap-3 justify-center flex-wrap;
|
||||
}
|
||||
|
||||
.error-button {
|
||||
@apply flex flex-col items-center px-6 py-4 border-2 border-gray-300 rounded-lg hover:border-red-500 hover:bg-red-50 transition-all cursor-pointer min-w-[100px] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.error-button.selected {
|
||||
@apply border-red-600 bg-red-100 ring-2 ring-red-500;
|
||||
}
|
||||
|
||||
.error-button.no-error {
|
||||
@apply border-green-300 hover:border-green-500 hover:bg-green-50;
|
||||
}
|
||||
|
||||
.error-button.no-error.selected {
|
||||
@apply border-green-600 bg-green-100 ring-2 ring-green-500;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
@apply text-xl font-bold text-gray-900 mb-1;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
@apply text-xs text-gray-600 text-center;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.wizard-actions {
|
||||
@apply pt-4 border-t border-gray-200;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
@apply w-full inline-flex items-center justify-center gap-2 px-6 py-4 bg-blue-600 text-white font-bold text-lg rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-400;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.x-check-wizard {
|
||||
@apply p-4 space-y-4;
|
||||
}
|
||||
|
||||
.dice-display {
|
||||
@apply flex-col gap-4;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
@apply grid-cols-2 gap-2;
|
||||
}
|
||||
|
||||
.chart-column {
|
||||
@apply p-3;
|
||||
}
|
||||
|
||||
.column-result {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
@apply grid grid-cols-3 gap-2;
|
||||
}
|
||||
|
||||
.error-button {
|
||||
@apply px-4 py-3 min-w-0;
|
||||
}
|
||||
|
||||
.sub-choice-buttons {
|
||||
@apply flex-col;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
frontend-sba/constants/xCheckResults.ts
Normal file
87
frontend-sba/constants/xCheckResults.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* X-Check Result Code Labels and Descriptions
|
||||
*
|
||||
* Display labels for all possible result codes that can appear in
|
||||
* defensive x-check chart rows (5 columns).
|
||||
*/
|
||||
|
||||
export const X_CHECK_RESULT_LABELS: Record<string, string> = {
|
||||
// Groundball results
|
||||
G1: 'Groundball Out (best)',
|
||||
G2: 'Groundball Out (good)',
|
||||
G3: 'Groundball Out (weak)',
|
||||
'G2#': 'Groundball (speed test)',
|
||||
'G3#': 'Groundball (speed test)',
|
||||
|
||||
// Singles
|
||||
SI1: 'Single (clean)',
|
||||
SI2: 'Single (through)',
|
||||
|
||||
// Doubles
|
||||
DO2: 'Double (2-base)',
|
||||
DO3: 'Double (3-base)',
|
||||
|
||||
// Triples
|
||||
TR3: 'Triple',
|
||||
|
||||
// Flyball results
|
||||
F1: 'Flyout (deep)',
|
||||
F2: 'Flyout (medium)',
|
||||
F3: 'Flyout (shallow)',
|
||||
|
||||
// Catcher-specific
|
||||
SPD: 'Speed Check',
|
||||
FO: 'Fly Out',
|
||||
PO: 'Pop Out',
|
||||
}
|
||||
|
||||
export const X_CHECK_ERROR_LABELS: Record<string, string> = {
|
||||
NO: 'No Error',
|
||||
E1: 'Error (+1 base)',
|
||||
E2: 'Error (+2 bases)',
|
||||
E3: 'Error (+3 bases)',
|
||||
RP: 'Rare Play (+3 bases)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash result conversions (player mentally resolves based on batter speed)
|
||||
*/
|
||||
export const HASH_CONVERSIONS: Record<string, string[]> = {
|
||||
'G2#': ['G2', 'SI2'],
|
||||
'G3#': ['G3', 'SI2'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for a result code
|
||||
*/
|
||||
export function getResultLabel(code: string): string {
|
||||
return X_CHECK_RESULT_LABELS[code] || code
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for an error result
|
||||
*/
|
||||
export function getErrorLabel(code: string): string {
|
||||
return X_CHECK_ERROR_LABELS[code] || code
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a result code is a hash result (requires speed test)
|
||||
*/
|
||||
export function isHashResult(code: string): boolean {
|
||||
return code.endsWith('#')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a result code is SPD (speed check)
|
||||
*/
|
||||
export function isSpdResult(code: string): boolean {
|
||||
return code === 'SPD'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversion options for a hash result
|
||||
*/
|
||||
export function getHashConversions(code: string): string[] | null {
|
||||
return HASH_CONVERSIONS[code] || null
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user