strat-gameplay-webapp/frontend-sba/components/Gameplay/DiceShapes.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

178 lines
5.0 KiB
Vue

<template>
<!-- D6 Die Shape -->
<div v-if="type === 'd6'" class="die-container" :style="containerStyle">
<svg :viewBox="viewBox" class="die-shape">
<!-- Die body with rounded corners -->
<rect
x="4"
y="4"
:width="size - 8"
:height="size - 8"
rx="10"
ry="10"
:fill="fillColor"
:stroke="strokeColor"
stroke-width="2"
/>
<!-- Corner dots (decorative, suggesting die pips) -->
<circle :cx="size * 0.2" :cy="size * 0.2" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
<circle :cx="size * 0.8" :cy="size * 0.2" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
<circle :cx="size * 0.2" :cy="size * 0.8" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
<circle :cx="size * 0.8" :cy="size * 0.8" :r="size * 0.04" :fill="dotColor" opacity="0.4" />
</svg>
<div class="die-value" :style="valueStyle">
<slot>{{ value }}</slot>
</div>
<div v-if="label" class="die-label">{{ label }}</div>
<div v-if="sublabel" class="die-sublabel">{{ sublabel }}</div>
</div>
<!-- D20 Die Shape (Hexagonal/Icosahedron-inspired) -->
<div v-else-if="type === 'd20'" class="die-container" :style="containerStyle">
<svg :viewBox="viewBox" class="die-shape">
<!-- Hexagonal shape suggesting a d20 -->
<polygon
:points="hexagonPoints"
:fill="fillColor"
:stroke="strokeColor"
stroke-width="2"
/>
<!-- Inner facet lines for 3D effect -->
<line
:x1="size * 0.5"
:y1="size * 0.1"
:x2="size * 0.5"
:y2="size * 0.35"
:stroke="facetColor"
stroke-width="1"
opacity="0.3"
/>
<line
:x1="size * 0.5"
:y1="size * 0.65"
:x2="size * 0.5"
:y2="size * 0.9"
:stroke="facetColor"
stroke-width="1"
opacity="0.3"
/>
</svg>
<div class="die-value die-value-large" :style="valueStyle">
<slot>{{ value }}</slot>
</div>
<div v-if="label" class="die-label">{{ label }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
type: 'd6' | 'd20'
value: number | string
color?: string // Hex color without # (e.g., "cc0000")
size?: number // Size in pixels (default 100)
label?: string
sublabel?: string
}
const props = withDefaults(defineProps<Props>(), {
color: 'cc0000', // Default red
size: 100,
})
// Computed styles
const viewBox = computed(() => `0 0 ${props.size} ${props.size}`)
const containerStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}))
const fillColor = computed(() => `#${props.color}`)
const strokeColor = computed(() => {
// Darken the fill color for stroke
return darkenColor(props.color, 0.2)
})
const dotColor = computed(() => {
// Use white or dark based on color luminance
return isLightColor(props.color) ? '#333333' : '#ffffff'
})
const facetColor = computed(() => {
return isLightColor(props.color) ? '#000000' : '#ffffff'
})
const valueStyle = computed(() => ({
color: isLightColor(props.color) ? '#1a1a1a' : '#ffffff',
}))
// Hexagon points for d20
const hexagonPoints = computed(() => {
const s = props.size
const cx = s / 2
const cy = s / 2
const r = s * 0.42 // Radius
// 6-sided hexagon rotated to have flat top
const points = []
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 2
const x = cx + r * Math.cos(angle)
const y = cy + r * Math.sin(angle)
points.push(`${x},${y}`)
}
return points.join(' ')
})
// Helper: Check if color is light (for text contrast)
function isLightColor(hex: string): boolean {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
// Using relative luminance formula
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5
}
// Helper: Darken a hex color
function darkenColor(hex: string, amount: number): string {
const r = Math.max(0, Math.floor(parseInt(hex.slice(0, 2), 16) * (1 - amount)))
const g = Math.max(0, Math.floor(parseInt(hex.slice(2, 4), 16) * (1 - amount)))
const b = Math.max(0, Math.floor(parseInt(hex.slice(4, 6), 16) * (1 - amount)))
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
</script>
<style scoped>
.die-container {
@apply relative flex flex-col items-center justify-center;
}
.die-shape {
@apply absolute inset-0 w-full h-full;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
}
.die-value {
@apply relative z-10 font-bold text-3xl;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.die-value-large {
@apply text-4xl;
}
.die-label {
@apply absolute -bottom-6 left-1/2 -translate-x-1/2;
@apply text-xs font-semibold text-gray-300 uppercase tracking-wide whitespace-nowrap;
}
.die-sublabel {
@apply absolute -bottom-10 left-1/2 -translate-x-1/2;
@apply text-xs text-gray-400 whitespace-nowrap;
}
</style>