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>
178 lines
5.0 KiB
Vue
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>
|