diff --git a/apps/web/server.js b/apps/web/server.js index 18dfdde2..3c9703d2 100644 --- a/apps/web/server.js +++ b/apps/web/server.js @@ -55,7 +55,7 @@ app.prepare().then(() => { // Log all upgrade requests to see handler execution order const originalEmit = server.emit.bind(server) - server.emit = function (event, ...args) { + server.emit = (event, ...args) => { if (event === 'upgrade') { const req = args[0] console.log(`\n🔄 UPGRADE REQUEST: ${req.url}`) diff --git a/apps/web/src/arcade-games/rithmomachia/components/PieceRenderer.tsx b/apps/web/src/arcade-games/rithmomachia/components/PieceRenderer.tsx index 4154fc56..549bf813 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/PieceRenderer.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/PieceRenderer.tsx @@ -8,74 +8,118 @@ interface PieceRendererProps { } /** - * SVG-based piece renderer with precise color control. - * BLACK pieces: dark fill, point RIGHT (towards white) - * WHITE pieces: light fill, point LEFT (towards black) + * SVG-based piece renderer with enhanced visual treatment. + * BLACK pieces: dark gradient fill with light border, point RIGHT (towards white) + * WHITE pieces: light gradient fill with dark border, point LEFT (towards black) */ export function PieceRenderer({ type, color, value, size = 48 }: PieceRendererProps) { const isDark = color === 'B' - const fillColor = isDark ? '#1a1a1a' : '#e8e8e8' - const strokeColor = isDark ? '#000000' : '#333333' + + // Gradient IDs + const gradientId = `gradient-${type}-${color}-${size}` + const shadowId = `shadow-${type}-${color}-${size}` + + // Enhanced colors with gradients + const gradientStart = isDark ? '#2d2d2d' : '#ffffff' + const gradientEnd = isDark ? '#0a0a0a' : '#d0d0d0' + const strokeColor = isDark ? '#ffffff' : '#1a1a1a' const textColor = isDark ? '#ffffff' : '#000000' // Calculate responsive font size based on value length const valueStr = value.toString() - const baseSize = type === 'P' ? size * 0.18 : size * 0.28 + const baseSize = type === 'P' ? size * 0.18 : size * 0.35 let fontSize = baseSize if (valueStr.length >= 3) { - fontSize = baseSize * 0.7 // 3+ digits: smaller + fontSize = baseSize * 0.65 // 3+ digits: smaller } else if (valueStr.length === 2) { - fontSize = baseSize * 0.85 // 2 digits: slightly smaller + fontSize = baseSize * 0.8 // 2 digits: slightly smaller } const renderShape = () => { switch (type) { case 'C': // Circle return ( - + + + + ) case 'T': // Triangle - BLACK points RIGHT, WHITE points LEFT if (isDark) { // Black triangle points RIGHT (towards white) return ( - + + + + ) } else { // White triangle points LEFT (towards black) return ( - + + + + ) } case 'S': // Square return ( - + + + + ) case 'P': { @@ -90,9 +134,11 @@ export function PieceRenderer({ type, color, value, size = 48 }: PieceRendererPr y={size * 0.1} width={size * 0.3} height={size * 0.15} - fill={fillColor} + fill={`url(#${gradientId})`} stroke={strokeColor} - strokeWidth={1.5} + strokeWidth={2} + opacity={0.9} + filter={`url(#${shadowId})`} /> {/* Second bar */} {/* Third bar */} {/* Bottom/largest bar */} ) @@ -135,27 +187,85 @@ export function PieceRenderer({ type, color, value, size = 48 }: PieceRendererPr return ( + + {/* Gradient definition */} + {type === 'C' ? ( + + + + + ) : ( + + + + + )} + + {/* Shadow filter */} + + + + + {/* Text shadow for dark pieces */} + {isDark && ( + + + + )} + + {renderShape()} + {/* Pyramids don't show numbers */} {type !== 'P' && ( - - {value} - + + {/* Outer glow/shadow for emphasis */} + {isDark ? ( + + {value} + + ) : ( + + {value} + + )} + {/* Main text */} + + {value} + + )} ) diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx index d4dfeaeb..d22ed680 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation' import { useEffect, useRef, useState } from 'react' import { animated, useSpring } from '@react-spring/web' +import * as Tooltip from '@radix-ui/react-tooltip' import { PageWithNav } from '@/components/PageWithNav' import { StandardGameLayout } from '@/components/StandardGameLayout' import { useFullscreen } from '@/contexts/FullscreenContext' @@ -436,50 +437,218 @@ function PlayingPhase() { } /** - * Animated piece component that smoothly transitions between squares. + * Animated floating capture relation options */ -function AnimatedPiece({ - piece, - gridSize, +function CaptureRelationOptions({ + targetPos, + cellSize, + gap, + onSelectRelation, }: { - piece: Piece - gridSize: { width: number; height: number } + targetPos: { x: number; y: number } + cellSize: number + gap: number + onSelectRelation: (relation: string) => void }) { - // Parse square to get column and row - const file = piece.square.charCodeAt(0) - 65 // A=0, B=1, etc. - const rank = Number.parseInt(piece.square.slice(1), 10) // 1-8 + const relations = [ + { relation: 'EQUAL', label: '=', tooltip: 'Equality: a = b', angle: 0, color: '#8b5cf6' }, + { + relation: 'MULTIPLE', + label: '×n', + tooltip: 'Multiple: b is multiple of a', + angle: 51.4, + color: '#a855f7', + }, + { + relation: 'DIVISOR', + label: '÷', + tooltip: 'Divisor: a divides b', + angle: 102.8, + color: '#c084fc', + }, + { + relation: 'SUM', + label: '+', + tooltip: 'Sum: a + h = b (helper)', + angle: 154.3, + color: '#3b82f6', + }, + { + relation: 'DIFF', + label: '−', + tooltip: 'Difference: |a - h| = b (helper)', + angle: 205.7, + color: '#06b6d4', + }, + { + relation: 'PRODUCT', + label: '×', + tooltip: 'Product: a × h = b (helper)', + angle: 257.1, + color: '#10b981', + }, + { + relation: 'RATIO', + label: '÷÷', + tooltip: 'Ratio: a/h = b/h (helper)', + angle: 308.6, + color: '#f59e0b', + }, + ] - // Calculate position (inverted rank for display: rank 8 = row 0) - const col = file - const row = 8 - rank + const maxRadius = cellSize * 1.2 + const buttonSize = 64 - // Animate position changes + // Animate all buttons simultaneously (not trail) const spring = useSpring({ - left: `${(col / 16) * 100}%`, - top: `${(row / 8) * 100}%`, - config: { tension: 280, friction: 60 }, + from: { radius: 0, opacity: 0 }, + to: { radius: maxRadius, opacity: 0.85 }, + config: { tension: 280, friction: 20 }, }) return ( - - - + + + {relations.map(({ relation, label, tooltip, angle, color }) => { + const rad = (angle * Math.PI) / 180 + + return ( + + `translate(${targetPos.x + Math.cos(rad) * r}, ${targetPos.y + Math.sin(rad) * r})` + )} + > + + + + { + e.stopPropagation() + onSelectRelation(relation) + }} + style={{ + width: buttonSize, + height: buttonSize, + borderRadius: '50%', + border: '3px solid rgba(255, 255, 255, 0.9)', + backgroundColor: color, + color: 'white', + fontSize: '28px', + fontWeight: 'bold', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + opacity: spring.opacity, + transition: 'all 0.15s ease', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', + textShadow: '0 2px 4px rgba(0, 0, 0, 0.5)', + }} + onMouseEnter={(e) => { + e.currentTarget.style.opacity = '1' + e.currentTarget.style.transform = 'scale(1.15)' + e.currentTarget.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.4)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = '0.85' + e.currentTarget.style.transform = 'scale(1)' + e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)' + }} + > + {label} + + + + + + {tooltip} + + + + + + + + ) + })} + + + ) +} + +/** + * Render a piece within SVG coordinates + */ +function SvgPiece({ + piece, + cellSize, + padding, +}: { + piece: Piece + cellSize: number + padding: number +}) { + const file = piece.square.charCodeAt(0) - 65 // A=0 + const rank = Number.parseInt(piece.square.slice(1), 10) // 1-8 + const row = 8 - rank // Invert for display + + const x = padding + file * cellSize + const y = padding + row * cellSize + + const spring = useSpring({ + x, + y, + config: { tension: 280, friction: 60 }, + }) + + const pieceSize = cellSize * 0.85 + + return ( + `translate(${xVal}, ${spring.y.get()})`)}> + + + + + + ) } @@ -490,7 +659,12 @@ function BoardDisplay() { const { state, makeMove, playerColor, isMyTurn } = useRithmomachia() const [selectedSquare, setSelectedSquare] = useState(null) const [captureDialogOpen, setCaptureDialogOpen] = useState(false) - const [captureTarget, setCaptureTarget] = useState<{ from: string; to: string; pieceId: string } | null>(null) + const [captureTarget, setCaptureTarget] = useState<{ + from: string + to: string + pieceId: string + } | null>(null) + const [hoveredRelation, setHoveredRelation] = useState(null) const handleSquareClick = (square: string, piece: (typeof state.pieces)[string] | undefined) => { if (!isMyTurn) return @@ -534,7 +708,20 @@ function BoardDisplay() { const handleCaptureWithRelation = (relation: string) => { if (captureTarget) { - makeMove(captureTarget.from, captureTarget.to, captureTarget.pieceId, relation) + // Get target piece ID + const targetPiece = Object.values(state.pieces).find( + (p) => p.square === captureTarget.to && !p.captured + ) + if (!targetPiece) return + + const captureData = { + relation: relation as any, // RelationKind + targetPieceId: targetPiece.id, + // TODO: For relations that require helpers (SUM, DIFF, PRODUCT, RATIO), + // we need to add UI for selecting helper pieces. For now, just try without helper. + } + + makeMove(captureTarget.from, captureTarget.to, captureTarget.pieceId, undefined, captureData) setCaptureDialogOpen(false) setCaptureTarget(null) setSelectedSquare(null) @@ -544,247 +731,120 @@ function BoardDisplay() { // Get all active pieces const activePieces = Object.values(state.pieces).filter((p) => !p.captured) - return ( - <> - {/* Capture relation dialog */} - {captureDialogOpen && ( - { - setCaptureDialogOpen(false) - setCaptureTarget(null) - }} - > - e.stopPropagation()} - > - - Select Capture Relation - - - Choose the mathematical relation for this capture: - - - handleCaptureWithRelation('EQUAL')} - className={css({ - px: '4', - py: '3', - bg: 'purple.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'purple.200' }, - })} - > - Equality: Mover value = Target value - - handleCaptureWithRelation('MULTIPLE')} - className={css({ - px: '4', - py: '3', - bg: 'purple.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'purple.200' }, - })} - > - Multiple: Target is a multiple of Mover - - handleCaptureWithRelation('DIVISOR')} - className={css({ - px: '4', - py: '3', - bg: 'purple.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'purple.200' }, - })} - > - Divisor: Mover is a divisor of Target - - handleCaptureWithRelation('SUM')} - className={css({ - px: '4', - py: '3', - bg: 'blue.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'blue.200' }, - })} - > - Sum: Mover + Helper = Target (requires helper) - - handleCaptureWithRelation('DIFF')} - className={css({ - px: '4', - py: '3', - bg: 'blue.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'blue.200' }, - })} - > - Difference: |Mover - Helper| = Target (requires helper) - - handleCaptureWithRelation('PRODUCT')} - className={css({ - px: '4', - py: '3', - bg: 'blue.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'blue.200' }, - })} - > - Product: Mover × Helper = Target (requires helper) - - handleCaptureWithRelation('RATIO')} - className={css({ - px: '4', - py: '3', - bg: 'blue.100', - borderRadius: 'md', - textAlign: 'left', - cursor: 'pointer', - _hover: { bg: 'blue.200' }, - })} - > - Ratio: Mover / Helper = Target / Helper (requires helper) - - - { - setCaptureDialogOpen(false) - setCaptureTarget(null) - }} - className={css({ - mt: '4', - px: '4', - py: '2', - bg: 'gray.200', - borderRadius: 'md', - cursor: 'pointer', - _hover: { bg: 'gray.300' }, - width: '100%', - })} - > - Cancel - - - - )} + // SVG dimensions + const cols = 16 + const rows = 8 + const cellSize = 100 // SVG units per cell + const gap = 2 + const padding = 10 + const boardWidth = cols * cellSize + (cols - 1) * gap + padding * 2 + const boardHeight = rows * cellSize + (rows - 1) * gap + padding * 2 - ) => { + if (!isMyTurn) return + + // If capture dialog is open, dismiss it on any click (buttons have stopPropagation) + if (captureDialogOpen) { + setCaptureDialogOpen(false) + setCaptureTarget(null) + return + } + + const svg = e.currentTarget + const rect = svg.getBoundingClientRect() + const x = ((e.clientX - rect.left) / rect.width) * boardWidth - padding + const y = ((e.clientY - rect.top) / rect.height) * boardHeight - padding + + // Convert to grid coordinates + const col = Math.floor(x / (cellSize + gap)) + const row = Math.floor(y / (cellSize + gap)) + + if (col >= 0 && col < cols && row >= 0 && row < rows) { + const square = `${String.fromCharCode(65 + col)}${8 - row}` + const piece = Object.values(state.pieces).find((p) => p.square === square && !p.captured) + handleSquareClick(square, piece) + } + } + + // Calculate target square position for floating capture options + const getTargetSquarePosition = () => { + if (!captureTarget) return null + const file = captureTarget.to.charCodeAt(0) - 65 + const rank = Number.parseInt(captureTarget.to.slice(1), 10) + const row = 8 - rank + const x = padding + file * (cellSize + gap) + cellSize / 2 + const y = padding + row * (cellSize + gap) + cellSize / 2 + return { x, y } + } + + const targetPos = getTargetSquarePosition() + + return ( + - {/* Board grid */} - - {Array.from({ length: 8 }, (_, rank) => { - const actualRank = 8 - rank - return Array.from({ length: 16 }, (_, file) => { - const square = `${String.fromCharCode(65 + file)}${actualRank}` + {/* Board background */} + + + {/* Board squares */} + {Array.from({ length: rows }, (_, row) => { + const actualRank = 8 - row + return Array.from({ length: cols }, (_, col) => { + const square = `${String.fromCharCode(65 + col)}${actualRank}` const piece = Object.values(state.pieces).find( (p) => p.square === square && !p.captured ) - const isLight = (file + actualRank) % 2 === 0 + const isLight = (col + actualRank) % 2 === 0 const isSelected = selectedSquare === square + const x = padding + col * (cellSize + gap) + const y = padding + row * (cellSize + gap) + return ( - handleSquareClick(square, piece)} - className={css({ - bg: isSelected ? 'yellow.300' : isLight ? 'gray.100' : 'gray.200', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'column', - fontSize: 'xs', - aspectRatio: '1', - position: 'relative', - cursor: isMyTurn ? 'pointer' : 'default', - _hover: isMyTurn - ? { bg: isSelected ? 'yellow.400' : isLight ? 'purple.100' : 'purple.200' } - : {}, - border: isSelected ? '2px solid' : 'none', - borderColor: 'purple.600', - })} + x={x} + y={y} + width={cellSize} + height={cellSize} + fill={isSelected ? '#fde047' : isLight ? '#f3f4f6' : '#e5e7eb'} + stroke={isSelected ? '#9333ea' : 'none'} + strokeWidth={isSelected ? 2 : 0} /> ) }) })} - - {/* Animated pieces layer - matches board padding */} - + {/* Pieces */} {activePieces.map((piece) => ( - + ))} - + + {/* Floating capture relation options */} + {captureDialogOpen && targetPos && ( + + )} + - > ) }
- Choose the mathematical relation for this capture: -