From 976a7de949c22842f4b6da3ced990f502a1c2733 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 29 Oct 2025 12:14:13 -0500 Subject: [PATCH] feat(rithmomachia): use actual piece SVGs in number bond with 2.5s rotation animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completely revamps the number bond visualization to match user specifications: **Number Bond Layout:** - Operands (mover + helper) positioned at TOP (left and right) - Result (target) positioned at BOTTOM center - Operator symbol displayed prominently in center - Triangle lines connect all three pieces - Uses actual PieceRenderer SVG components instead of circles **2.5 Second Capture Animation:** When user clicks "✓ Capture" button: - All three pieces begin rotating around center point - Rotation accelerates to near-infinite speed (20π radians = 10 full rotations) - Pieces spiral inward (radius collapses to 0) - Helper and target pieces fade out (opacity → 0) - Mover piece remains visible at center - Animation duration: exactly 2.5 seconds - After animation completes, capture is executed via onRest callback **Technical Implementation:** - Two-phase animation system: - Entrance: scale from 0 with spring physics - Capture: duration-based rotation/collapse with configurable easing - Each piece offset by 120° (2π/3) for balanced rotation - Distance calculation: `spacing * 0.7 * radius` for smooth spiral - Mover stays at opacity 1, helper/target fade during animation - Lines and operator hide during animation for clean visual **State Changes:** - Updated selectedHelper state to store full Piece objects - Simplified handleHelperSelection (no value extraction) - Updated handleNumberBondConfirm to use piece.id references - Render section passes pieces instead of primitive values This creates a dramatic, mathematically educational capture animation that clearly shows the relationship before executing the capture. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/RithmomachiaGame.tsx | 415 +++++++++--------- 1 file changed, 216 insertions(+), 199 deletions(-) diff --git a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx index 32de31cd..c395db27 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/RithmomachiaGame.tsx @@ -609,35 +609,30 @@ function HelperSelectionOptions({ } /** - * Number Bond Triangle Visualization - shows the mathematical relationship + * Number Bond Visualization - shows mathematical relationship with actual piece SVGs + * Layout: Operands (mover + helper) at top, result (target) at bottom + * Animation: 2.5s rotation collapse when confirmed */ function NumberBondVisualization({ - moverValue, - helperValue, - targetValue, + moverPiece, + helperPiece, + targetPiece, relation, targetPos, cellSize, onConfirm, closing = false, }: { - moverValue: number - helperValue: number - targetValue: number + moverPiece: Piece + helperPiece: Piece + targetPiece: Piece relation: RelationKind targetPos: { x: number; y: number } cellSize: number onConfirm: () => void closing?: boolean }) { - // Triangle layout: target at top, mover and helper at bottom corners - const triangleSize = cellSize * 1.5 - const topPos = { x: targetPos.x, y: targetPos.y - triangleSize * 0.6 } - const leftPos = { x: targetPos.x - triangleSize * 0.5, y: targetPos.y + triangleSize * 0.4 } - const rightPos = { x: targetPos.x + triangleSize * 0.5, y: targetPos.y + triangleSize * 0.4 } - - const circleRadius = cellSize * 0.4 - const fontSize = cellSize * 0.35 + const [animating, setAnimating] = useState(false) // Color scheme based on relation type const colorMap: Record = { @@ -651,14 +646,6 @@ function NumberBondVisualization({ } const color = colorMap[relation] || '#8b5cf6' - // Animate in with spring - const spring = useSpring({ - from: { scale: 0, opacity: 0 }, - scale: closing ? 0 : 1, - opacity: closing ? 0 : 1, - config: { tension: 280, friction: 20 }, - }) - // Operation symbol based on relation const operatorMap: Record = { SUM: '+', @@ -671,163 +658,215 @@ function NumberBondVisualization({ } const operator = operatorMap[relation] + // Layout: operands at top, result at bottom + const spacing = cellSize * 1.8 + const moverPos = { x: targetPos.x - spacing * 0.5, y: targetPos.y - spacing * 0.7 } + const helperPos = { x: targetPos.x + spacing * 0.5, y: targetPos.y - spacing * 0.7 } + const resultPos = { x: targetPos.x, y: targetPos.y + spacing * 0.5 } + + // Animation: 2.5s rotate and collapse + const captureAnimation = useSpring({ + from: { rotation: 0, radius: 1, opacity: 1 }, + rotation: animating ? Math.PI * 20 : 0, // 10 full rotations + radius: animating ? 0 : 1, + opacity: animating ? 0 : 1, + config: animating ? { duration: 2500 } : { tension: 280, friction: 20 }, + onRest: () => { + if (animating) { + onConfirm() + } + }, + }) + + // Initial entrance animation + const entranceSpring = useSpring({ + from: { scale: 0, opacity: 0 }, + scale: closing || animating ? 0 : 1, + opacity: closing ? 0 : 1, + config: { tension: 280, friction: 20 }, + }) + + const handleConfirm = (e: React.MouseEvent) => { + e.stopPropagation() + setAnimating(true) + } + + // Get piece values + const getMoverValue = () => getEffectiveValue(moverPiece) + const getHelperValue = () => getEffectiveValue(helperPiece) + const getTargetValue = () => getEffectiveValue(targetPiece) + return ( `translate(${targetPos.x}, ${targetPos.y}) scale(${s})`)} + transform={to( + [entranceSpring.scale], + (s) => `translate(${targetPos.x}, ${targetPos.y}) scale(${s})` + )} > - {/* Triangle connecting lines */} - - - + {!animating && ( + <> + {/* Triangle connecting lines */} + + + - {/* Target (top) */} - - - {targetValue} - + {/* Operator symbol in center */} + + {operator} + + + )} - {/* Mover (bottom left) */} - - { + if (!animating) { + return `translate(${moverPos.x}, ${moverPos.y})` + } + // During animation: rotate around center and collapse + const angle = rot + const distance = spacing * 0.7 * rad + const x = targetPos.x + Math.cos(angle) * distance + const y = targetPos.y + Math.sin(angle) * distance + return `translate(${x}, ${y})` + })} + opacity={animating ? 1 : 1} // Mover stays visible > - {moverValue} - + + + + - {/* Helper (bottom right) */} - - { + if (!animating) { + return `translate(${helperPos.x}, ${helperPos.y})` + } + const angle = rot + (Math.PI * 2) / 3 // Offset by 120 degrees + const distance = spacing * 0.7 * rad + const x = targetPos.x + Math.cos(angle) * distance + const y = targetPos.y + Math.sin(angle) * distance + return `translate(${x}, ${y})` + })} + opacity={to([captureAnimation.opacity], (op) => (animating ? op : 1))} > - {helperValue} - + + + + - {/* Operator symbol between bottom circles */} - { + if (!animating) { + return `translate(${resultPos.x}, ${resultPos.y})` + } + const angle = rot + (Math.PI * 4) / 3 // Offset by 240 degrees + const distance = spacing * 0.7 * rad + const x = targetPos.x + Math.cos(angle) * distance + const y = targetPos.y + Math.sin(angle) * distance + return `translate(${x}, ${y})` + })} + opacity={to([captureAnimation.opacity], (op) => (animating ? op : 1))} > - {operator} - + + + + {/* Confirm button */} - - - - - + + + + )} ) @@ -1081,10 +1120,9 @@ function BoardDisplay() { const [hoveredRelation, setHoveredRelation] = useState(null) const [selectedRelation, setSelectedRelation] = useState(null) const [selectedHelper, setSelectedHelper] = useState<{ - pieceId: string - moverValue: number - helperValue: number - targetValue: number + helperPiece: Piece + moverPiece: Piece + targetPiece: Piece } | null>(null) // Handle closing animation completion @@ -1300,7 +1338,7 @@ function BoardDisplay() { const handleHelperSelection = (helperPieceId: string) => { if (!captureTarget || !selectedRelation) return - // Get piece values for number bond visualization + // Get pieces for number bond visualization const moverPiece = Object.values(state.pieces).find( (p) => p.id === captureTarget.pieceId && !p.captured ) @@ -1313,42 +1351,21 @@ function BoardDisplay() { if (!moverPiece || !targetPiece || !helperPiece) return - const moverValue = getEffectiveValue(moverPiece) - const targetValue = getEffectiveValue(targetPiece) - const helperValue = getEffectiveValue(helperPiece) - - if ( - moverValue === undefined || - moverValue === null || - targetValue === undefined || - targetValue === null || - helperValue === undefined || - helperValue === null - ) { - return - } - - // Show number bond instead of immediately executing + // Show number bond visualization setSelectedHelper({ - pieceId: helperPieceId, - moverValue, - helperValue, - targetValue, + helperPiece, + moverPiece, + targetPiece, }) } const handleNumberBondConfirm = () => { if (!captureTarget || !selectedRelation || !selectedHelper) return - const targetPiece = Object.values(state.pieces).find( - (p) => p.square === captureTarget.to && !p.captured - ) - if (!targetPiece) return - const captureData = { relation: selectedRelation, - targetPieceId: targetPiece.id, - helperPieceId: selectedHelper.pieceId, + targetPieceId: selectedHelper.targetPiece.id, + helperPieceId: selectedHelper.helperPiece.id, } makeMove(captureTarget.from, captureTarget.to, captureTarget.pieceId, undefined, captureData) @@ -1580,9 +1597,9 @@ function BoardDisplay() { console.log('[Render] Showing NumberBondVisualization') return (