Spaces:
Sleeping
Sleeping
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: #0a0a0f; | |
| overflow: hidden; | |
| font-family: monospace; | |
| } | |
| #info { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| color: #666; | |
| font-size: 11px; | |
| pointer-events: none; | |
| } | |
| #piece-tooltip { | |
| position: absolute; | |
| background: rgba(15, 15, 20, 0.92); | |
| border: 1px solid #506070; | |
| border-radius: 6px; | |
| padding: 8px 12px; | |
| color: #ccc; | |
| font-size: 11px; | |
| pointer-events: none; | |
| display: none; | |
| max-width: 200px; | |
| z-index: 1000; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); | |
| } | |
| #piece-tooltip .piece-name { | |
| color: #A0A0A0; | |
| font-weight: bold; | |
| font-size: 13px; | |
| margin-bottom: 5px; | |
| } | |
| #piece-tooltip .move-count { | |
| color: #B09040; | |
| } | |
| #piece-tooltip .special { | |
| color: #906070; | |
| font-style: italic; | |
| margin-top: 4px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info">Drag to rotate • Scroll to zoom • Hover pieces for legal moves</div> | |
| <div id="piece-tooltip"></div> | |
| <div id="replay-overlay" style="display:none; position:absolute; top:15px; right:15px; background:rgba(10,10,20,0.9); border:2px solid #505060; border-radius:8px; padding:12px 18px; color:#ccc; font-family:monospace; z-index:1000; min-width:160px;"> | |
| <div style="color:#D08030; font-weight:bold; font-size:13px; margin-bottom:8px; border-bottom:1px solid #404050; padding-bottom:6px;">🎬 REPLAY</div> | |
| <div id="replay-move" style="font-size:22px; color:#80B0C0; font-weight:bold; margin-bottom:6px;">e2e4</div> | |
| <div id="replay-counter" style="font-size:12px; color:#888;">Move 1 / 8</div> | |
| <div id="replay-progress" style="margin-top:8px; height:4px; background:#303040; border-radius:2px; overflow:hidden;"><div id="replay-bar" style="height:100%; background:#D08030; width:0%; transition:width 0.3s;"></div></div> | |
| </div> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| // ═══════════════════════════════════════════════════════════════ | |
| // THREE.JS CHESS SCENE | |
| // ═══════════════════════════════════════════════════════════════ | |
| let scene, camera, renderer, controls; | |
| let boardGroup, piecesGroup, arcsGroup, highlightGroup; | |
| let raycaster, mouse; | |
| let hoveredPiece = null; | |
| let currentFEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; | |
| // ═══════════════════════════════════════════════════════════════ | |
| // CINEMATIC CAMERA SYSTEM | |
| // ═══════════════════════════════════════════════════════════════ | |
| let cinematicMode = false; | |
| let cinematicTarget = { x: 0, y: 0, z: 0 }; | |
| let cinematicCameraPos = { x: 6, y: -10, z: 8 }; | |
| let cameraAnimating = false; | |
| let cinematicStartTime = 0; | |
| let cinematicDuration = 1500; // ms for camera sweep | |
| let cameraStartPos = { x: 0, y: 0, z: 0 }; | |
| let cameraEndPos = { x: 0, y: 0, z: 0 }; | |
| let targetStart = { x: 0, y: 0, z: 0 }; | |
| let targetEnd = { x: 0, y: 0, z: 0 }; | |
| let lastMoveFrom = null; | |
| let lastMoveTo = null; | |
| // Piece data storage for hover detection | |
| const pieceMap = new Map(); // Maps piece mesh to {type, square, isWhite} | |
| // Colors - SOLID MUTED PALETTE (no neon) | |
| const BOARD_LIGHT = 0xC4A060; // Warm oak | |
| const BOARD_DARK = 0x6B4423; // Dark walnut | |
| const BOARD_EDGE = 0x3A2510; // Dark wood frame | |
| const WHITE_PIECE = 0xE8E0D0; // Warm ivory | |
| const BLACK_PIECE = 0x252525; // Charcoal | |
| const GOLD = 0xB08020; // Muted bronze/gold | |
| const CYAN = 0x306080; // Steel blue | |
| const MAGENTA = 0x803050; // Burgundy | |
| const BG_COLOR = 0x0a0a0f; | |
| // Piece heights | |
| const PIECE_HEIGHT = { | |
| 'p': 0.5, 'n': 0.8, 'b': 0.9, 'r': 0.7, 'q': 1.1, 'k': 1.2 | |
| }; | |
| function init() { | |
| // Scene | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(BG_COLOR); | |
| // Camera | |
| camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); | |
| camera.position.set(6, -10, 8); | |
| camera.lookAt(0, 0, 0); | |
| // Renderer | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| document.body.appendChild(renderer.domElement); | |
| // Controls - THIS IS THE KEY - OrbitControls persists! | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.minDistance = 5; | |
| controls.maxDistance = 30; | |
| controls.target.set(0, 0, 0); | |
| // Lights | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.8); | |
| dirLight.position.set(5, 5, 10); | |
| scene.add(dirLight); | |
| const rimLight = new THREE.DirectionalLight(0x405570, 0.3); | |
| rimLight.position.set(-5, -5, 5); | |
| scene.add(rimLight); | |
| // Groups | |
| boardGroup = new THREE.Group(); | |
| piecesGroup = new THREE.Group(); | |
| arcsGroup = new THREE.Group(); | |
| highlightGroup = new THREE.Group(); | |
| scene.add(boardGroup); | |
| scene.add(piecesGroup); | |
| scene.add(arcsGroup); | |
| scene.add(highlightGroup); | |
| // Raycaster for hover detection | |
| raycaster = new THREE.Raycaster(); | |
| mouse = new THREE.Vector2(); | |
| // Create board | |
| createBoard(); | |
| // Initial position | |
| updateFromFEN('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'); | |
| // Handle resize | |
| window.addEventListener('resize', onResize); | |
| // Listen for messages from Dash | |
| window.addEventListener('message', handleMessage); | |
| // Mouse move for hover detection | |
| window.addEventListener('mousemove', onMouseMove); | |
| animate(); | |
| } | |
| function createBoard() { | |
| // Clear existing | |
| while(boardGroup.children.length) boardGroup.remove(boardGroup.children[0]); | |
| // Board squares | |
| for (let file = 0; file < 8; file++) { | |
| for (let rank = 0; rank < 8; rank++) { | |
| const isLight = (file + rank) % 2 === 1; | |
| const color = isLight ? BOARD_LIGHT : BOARD_DARK; | |
| const geo = new THREE.BoxGeometry(1, 1, 0.15); | |
| const mat = new THREE.MeshStandardMaterial({ | |
| color: color, | |
| roughness: 0.7, | |
| metalness: 0.1 | |
| }); | |
| const square = new THREE.Mesh(geo, mat); | |
| square.position.set(file - 3.5, rank - 3.5, -0.075); | |
| boardGroup.add(square); | |
| } | |
| } | |
| // Board frame | |
| const frameGeo = new THREE.BoxGeometry(8.4, 8.4, 0.2); | |
| const frameMat = new THREE.MeshStandardMaterial({ | |
| color: BOARD_EDGE, | |
| roughness: 0.8 | |
| }); | |
| const frame = new THREE.Mesh(frameGeo, frameMat); | |
| frame.position.set(0, 0, -0.2); | |
| boardGroup.add(frame); | |
| // File/rank labels | |
| const labels = 'abcdefgh'; | |
| // (Skip labels for cleaner look - can add later) | |
| } | |
| function createPiece(type, isWhite, x, y) { | |
| const height = PIECE_HEIGHT[type.toLowerCase()] || 0.6; | |
| const color = isWhite ? WHITE_PIECE : BLACK_PIECE; | |
| // Create layered crystal piece | |
| const group = new THREE.Group(); | |
| // Base cylinder | |
| const baseGeo = new THREE.CylinderGeometry(0.35, 0.4, 0.15, 16); | |
| const baseMat = new THREE.MeshStandardMaterial({ | |
| color: color, | |
| roughness: 0.3, | |
| metalness: 0.2 | |
| }); | |
| const base = new THREE.Mesh(baseGeo, baseMat); | |
| base.rotation.x = Math.PI / 2; | |
| base.position.z = 0.075; | |
| group.add(base); | |
| // Body | |
| const bodyGeo = new THREE.CylinderGeometry(0.2, 0.3, height * 0.6, 16); | |
| const bodyMat = new THREE.MeshStandardMaterial({ | |
| color: color, | |
| roughness: 0.2, | |
| metalness: 0.3, | |
| transparent: true, | |
| opacity: 0.9 | |
| }); | |
| const body = new THREE.Mesh(bodyGeo, bodyMat); | |
| body.rotation.x = Math.PI / 2; | |
| body.position.z = 0.15 + height * 0.3; | |
| group.add(body); | |
| // Top detail varies by piece type | |
| let topGeo; | |
| if (type.toLowerCase() === 'k') { | |
| // King - cross | |
| topGeo = new THREE.BoxGeometry(0.1, 0.3, 0.1); | |
| const cross1 = new THREE.Mesh(topGeo, baseMat); | |
| cross1.position.z = height + 0.15; | |
| group.add(cross1); | |
| const cross2 = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.1, 0.1), baseMat); | |
| cross2.position.z = height + 0.2; | |
| group.add(cross2); | |
| } else if (type.toLowerCase() === 'q') { | |
| // Queen - crown points | |
| for (let i = 0; i < 5; i++) { | |
| const angle = (i / 5) * Math.PI * 2; | |
| const point = new THREE.Mesh( | |
| new THREE.ConeGeometry(0.05, 0.15, 8), | |
| baseMat | |
| ); | |
| point.position.set( | |
| Math.cos(angle) * 0.15, | |
| Math.sin(angle) * 0.15, | |
| height + 0.1 | |
| ); | |
| group.add(point); | |
| } | |
| } else if (type.toLowerCase() === 'b') { | |
| // Bishop - mitre | |
| const mitre = new THREE.Mesh( | |
| new THREE.SphereGeometry(0.15, 16, 16), | |
| baseMat | |
| ); | |
| mitre.scale.z = 1.5; | |
| mitre.position.z = height; | |
| group.add(mitre); | |
| } else if (type.toLowerCase() === 'n') { | |
| // Knight - simple head shape | |
| const head = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.15, 0.25, 0.3), | |
| baseMat | |
| ); | |
| head.position.set(0.1, 0, height); | |
| head.rotation.z = 0.3; | |
| group.add(head); | |
| } else if (type.toLowerCase() === 'r') { | |
| // Rook - battlements | |
| const top = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.25, 0.2, 0.15, 16), | |
| baseMat | |
| ); | |
| top.rotation.x = Math.PI / 2; | |
| top.position.z = height; | |
| group.add(top); | |
| } else { | |
| // Pawn - simple ball top | |
| const ball = new THREE.Mesh( | |
| new THREE.SphereGeometry(0.12, 16, 16), | |
| baseMat | |
| ); | |
| ball.position.z = height; | |
| group.add(ball); | |
| } | |
| // Add glow ring at base for style - MUTED COLORS | |
| const glowGeo = new THREE.RingGeometry(0.35, 0.45, 32); | |
| const glowMat = new THREE.MeshBasicMaterial({ | |
| color: isWhite ? 0x405060 : 0x604050, | |
| transparent: true, | |
| opacity: 0.25, | |
| side: THREE.DoubleSide | |
| }); | |
| const glow = new THREE.Mesh(glowGeo, glowMat); | |
| glow.rotation.x = 0; | |
| glow.position.z = 0.01; | |
| group.add(glow); | |
| group.position.set(x - 3.5, y - 3.5, 0); | |
| // Store piece data for hover detection | |
| const square = y * 8 + x; | |
| group.userData = { | |
| type: type.toLowerCase(), | |
| isWhite: isWhite, | |
| square: square, | |
| file: x, | |
| rank: y | |
| }; | |
| return group; | |
| } | |
| function createArc(fromSq, toSq, prob, isCapture, isBlack, isHuman, isSelected) { | |
| const fromX = fromSq % 8 - 3.5; | |
| const fromY = Math.floor(fromSq / 8) - 3.5; | |
| const toX = toSq % 8 - 3.5; | |
| const toY = Math.floor(toSq / 8) - 3.5; | |
| const points = []; | |
| const segments = 20; | |
| // Selected arc is higher and more prominent | |
| const height = isSelected ? (2.5 + prob * 1.5) : (1.5 + prob * 1.5); | |
| for (let i = 0; i <= segments; i++) { | |
| const t = i / segments; | |
| const x = fromX + (toX - fromX) * t; | |
| const y = fromY + (toY - fromY) * t; | |
| const z = 0.5 + Math.sin(t * Math.PI) * height; | |
| points.push(new THREE.Vector3(x, y, z)); | |
| } | |
| const curve = new THREE.CatmullRomCurve3(points); | |
| // Selected arc is thicker | |
| const thickness = isSelected ? (0.08 + prob * 0.06) : (0.03 + prob * 0.05); | |
| const tubeGeo = new THREE.TubeGeometry(curve, 20, thickness, 8, false); | |
| // Color selection - SOLID MUTED COLORS | |
| let color; | |
| if (isSelected) { | |
| color = 0xE0E0E0; // Off-white for selected | |
| } else if (isHuman) { | |
| color = 0x2D5A3D; // Forest green for human moves | |
| } else if (isBlack) { | |
| color = isCapture ? 0x8B2020 : (prob > 0.3 ? 0xA05020 : 0x704050); | |
| } else { | |
| color = isCapture ? MAGENTA : (prob > 0.3 ? GOLD : CYAN); | |
| } | |
| const tubeMat = new THREE.MeshBasicMaterial({ | |
| color: color, | |
| transparent: true, | |
| opacity: isSelected ? 1.0 : (0.4 + prob * 0.5) | |
| }); | |
| return new THREE.Mesh(tubeGeo, tubeMat); | |
| } | |
| function updateFromFEN(fen) { | |
| // Clear pieces | |
| while(piecesGroup.children.length) piecesGroup.remove(piecesGroup.children[0]); | |
| const parts = fen.split(' '); | |
| const position = parts[0]; | |
| const rows = position.split('/'); | |
| for (let rank = 0; rank < 8; rank++) { | |
| const row = rows[7 - rank]; | |
| let file = 0; | |
| for (const char of row) { | |
| if (char >= '1' && char <= '8') { | |
| file += parseInt(char); | |
| } else { | |
| const isWhite = char === char.toUpperCase(); | |
| const piece = createPiece(char, isWhite, file, rank); | |
| piecesGroup.add(piece); | |
| file++; | |
| } | |
| } | |
| } | |
| } | |
| function updateCandidates(candidates) { | |
| // Clear arcs | |
| while(arcsGroup.children.length) arcsGroup.remove(arcsGroup.children[0]); | |
| if (!candidates || candidates.length === 0) return; | |
| for (const c of candidates) { | |
| const arc = createArc(c.from_sq, c.to_sq, c.prob, c.is_capture, c.is_black, c.is_human, c.is_selected); | |
| arcsGroup.add(arc); | |
| } | |
| } | |
| function handleMessage(event) { | |
| const data = event.data; | |
| if (!data || !data.type) return; | |
| if (data.type === 'update') { | |
| if (data.fen) { | |
| currentFEN = data.fen; | |
| updateFromFEN(data.fen); | |
| } | |
| if (data.candidates !== undefined) { | |
| updateCandidates(data.candidates); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // REPLAY OVERLAY - No camera movement, just show progress | |
| // ═══════════════════════════════════════════════════════════════ | |
| if (data.type === 'cinematic_start') { | |
| // Show replay overlay | |
| const overlay = document.getElementById('replay-overlay'); | |
| if (overlay) overlay.style.display = 'block'; | |
| console.log('[REPLAY] Started'); | |
| } | |
| if (data.type === 'cinematic_stop') { | |
| // Hide replay overlay | |
| const overlay = document.getElementById('replay-overlay'); | |
| if (overlay) overlay.style.display = 'none'; | |
| console.log('[REPLAY] Stopped'); | |
| } | |
| if (data.type === 'cinematic_move') { | |
| // Update replay overlay - NO camera movement | |
| const moveNum = data.move_num || 1; | |
| const totalMoves = data.total_moves || 1; | |
| const moveName = data.move_name || '?'; | |
| const isCapture = data.is_capture || false; | |
| const isCheck = data.is_check || false; | |
| // Update overlay display | |
| const moveEl = document.getElementById('replay-move'); | |
| const counterEl = document.getElementById('replay-counter'); | |
| const barEl = document.getElementById('replay-bar'); | |
| if (moveEl) { | |
| // Style based on move type | |
| let moveColor = '#80B0C0'; // Normal move | |
| let prefix = ''; | |
| if (isCapture) { moveColor = '#C06060'; prefix = '⚔ '; } | |
| if (isCheck) { moveColor = '#D0A030'; prefix = '♚ '; } | |
| moveEl.textContent = prefix + moveName; | |
| moveEl.style.color = moveColor; | |
| } | |
| if (counterEl) { | |
| counterEl.textContent = `Move ${moveNum} / ${totalMoves}`; | |
| } | |
| if (barEl) { | |
| barEl.style.width = `${(moveNum / totalMoves) * 100}%`; | |
| } | |
| console.log(`[REPLAY] Move ${moveNum}/${totalMoves}: ${moveName}`); | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // SMOOTH CAMERA ANIMATION | |
| // ═══════════════════════════════════════════════════════════════ | |
| function animateCameraTo(x, y, z, targetX, targetY, targetZ, duration) { | |
| cameraStartPos = { | |
| x: camera.position.x, | |
| y: camera.position.y, | |
| z: camera.position.z | |
| }; | |
| cameraEndPos = { x, y, z }; | |
| targetStart = { | |
| x: controls.target.x, | |
| y: controls.target.y, | |
| z: controls.target.z | |
| }; | |
| targetEnd = { x: targetX, y: targetY, z: targetZ }; | |
| cinematicStartTime = performance.now(); | |
| cinematicDuration = duration; | |
| cameraAnimating = true; | |
| } | |
| function updateCinematicCamera() { | |
| if (!cameraAnimating) return; | |
| const elapsed = performance.now() - cinematicStartTime; | |
| const progress = Math.min(elapsed / cinematicDuration, 1); | |
| // Smooth easing (ease-in-out cubic) | |
| const ease = progress < 0.5 | |
| ? 4 * progress * progress * progress | |
| : 1 - Math.pow(-2 * progress + 2, 3) / 2; | |
| // Interpolate camera position | |
| camera.position.x = cameraStartPos.x + (cameraEndPos.x - cameraStartPos.x) * ease; | |
| camera.position.y = cameraStartPos.y + (cameraEndPos.y - cameraStartPos.y) * ease; | |
| camera.position.z = cameraStartPos.z + (cameraEndPos.z - cameraStartPos.z) * ease; | |
| // Interpolate target | |
| controls.target.x = targetStart.x + (targetEnd.x - targetStart.x) * ease; | |
| controls.target.y = targetStart.y + (targetEnd.y - targetStart.y) * ease; | |
| controls.target.z = targetStart.z + (targetEnd.z - targetStart.z) * ease; | |
| camera.lookAt(controls.target); | |
| if (progress >= 1) { | |
| cameraAnimating = false; | |
| } | |
| } | |
| // Chess move generation helper | |
| function getLegalMoves(pieceData) { | |
| const { type, isWhite, file, rank } = pieceData; | |
| const moves = []; | |
| const pieceNames = { | |
| 'p': 'Pawn', 'n': 'Knight', 'b': 'Bishop', | |
| 'r': 'Rook', 'q': 'Queen', 'k': 'King' | |
| }; | |
| const name = pieceNames[type] || 'Piece'; | |
| const colorName = isWhite ? 'White' : 'Black'; | |
| let specialInfo = []; | |
| // Parse FEN to get board state | |
| const fenParts = currentFEN.split(' '); | |
| const position = fenParts[0]; | |
| const turn = fenParts[1] === 'w'; | |
| const castling = fenParts[2] || '-'; | |
| const enPassant = fenParts[3] || '-'; | |
| // Build board array from FEN | |
| const board = []; | |
| const rows = position.split('/'); | |
| for (let r = 7; r >= 0; r--) { | |
| const row = []; | |
| for (const char of rows[7 - r]) { | |
| if (char >= '1' && char <= '8') { | |
| for (let i = 0; i < parseInt(char); i++) row.push(null); | |
| } else { | |
| row.push({ type: char.toLowerCase(), isWhite: char === char.toUpperCase() }); | |
| } | |
| } | |
| board[r] = row; | |
| } | |
| const isBlocked = (f, r) => f < 0 || f > 7 || r < 0 || r > 7 || board[r]?.[f] !== null; | |
| const isEnemy = (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 && | |
| board[r]?.[f] !== null && board[r][f].isWhite !== isWhite; | |
| const isEmpty = (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 && board[r]?.[f] === null; | |
| if (type === 'p') { | |
| // Pawn moves | |
| const dir = isWhite ? 1 : -1; | |
| const startRank = isWhite ? 1 : 6; | |
| const promoRank = isWhite ? 7 : 0; | |
| // Forward one | |
| if (isEmpty(file, rank + dir)) { | |
| moves.push({ file: file, rank: rank + dir, capture: false }); | |
| // Forward two from start | |
| if (rank === startRank && isEmpty(file, rank + dir * 2)) { | |
| moves.push({ file: file, rank: rank + dir * 2, capture: false }); | |
| specialInfo.push("Can move 2 squares (first move)"); | |
| } | |
| } | |
| // Diagonal captures | |
| for (const df of [-1, 1]) { | |
| if (isEnemy(file + df, rank + dir)) { | |
| moves.push({ file: file + df, rank: rank + dir, capture: true }); | |
| } | |
| } | |
| // En passant | |
| if (enPassant !== '-') { | |
| const epFile = enPassant.charCodeAt(0) - 97; | |
| const epRank = parseInt(enPassant[1]) - 1; | |
| if (Math.abs(epFile - file) === 1 && epRank === rank + dir) { | |
| moves.push({ file: epFile, rank: epRank, capture: true, enPassant: true }); | |
| specialInfo.push("En passant available!"); | |
| } | |
| } | |
| // Promotion check | |
| if (rank + dir === promoRank && moves.some(m => m.rank === promoRank)) { | |
| specialInfo.push("Promotes to Q/R/B/N on next rank"); | |
| } | |
| } else if (type === 'n') { | |
| // Knight moves | |
| const knightMoves = [[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]]; | |
| for (const [df, dr] of knightMoves) { | |
| const nf = file + df, nr = rank + dr; | |
| if (nf >= 0 && nf <= 7 && nr >= 0 && nr <= 7) { | |
| if (isEmpty(nf, nr) || isEnemy(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: isEnemy(nf, nr) }); | |
| } | |
| } | |
| } | |
| } else if (type === 'b') { | |
| // Bishop moves (diagonals) | |
| for (const [df, dr] of [[1,1],[1,-1],[-1,1],[-1,-1]]) { | |
| for (let i = 1; i < 8; i++) { | |
| const nf = file + df * i, nr = rank + dr * i; | |
| if (nf < 0 || nf > 7 || nr < 0 || nr > 7) break; | |
| if (isEmpty(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: false }); | |
| } else if (isEnemy(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: true }); | |
| break; | |
| } else break; | |
| } | |
| } | |
| } else if (type === 'r') { | |
| // Rook moves (straight lines) | |
| for (const [df, dr] of [[1,0],[-1,0],[0,1],[0,-1]]) { | |
| for (let i = 1; i < 8; i++) { | |
| const nf = file + df * i, nr = rank + dr * i; | |
| if (nf < 0 || nf > 7 || nr < 0 || nr > 7) break; | |
| if (isEmpty(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: false }); | |
| } else if (isEnemy(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: true }); | |
| break; | |
| } else break; | |
| } | |
| } | |
| } else if (type === 'q') { | |
| // Queen moves (diagonals + straight) | |
| for (const [df, dr] of [[1,1],[1,-1],[-1,1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]]) { | |
| for (let i = 1; i < 8; i++) { | |
| const nf = file + df * i, nr = rank + dr * i; | |
| if (nf < 0 || nf > 7 || nr < 0 || nr > 7) break; | |
| if (isEmpty(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: false }); | |
| } else if (isEnemy(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: true }); | |
| break; | |
| } else break; | |
| } | |
| } | |
| } else if (type === 'k') { | |
| // King moves | |
| for (let df = -1; df <= 1; df++) { | |
| for (let dr = -1; dr <= 1; dr++) { | |
| if (df === 0 && dr === 0) continue; | |
| const nf = file + df, nr = rank + dr; | |
| if (nf >= 0 && nf <= 7 && nr >= 0 && nr <= 7) { | |
| if (isEmpty(nf, nr) || isEnemy(nf, nr)) { | |
| moves.push({ file: nf, rank: nr, capture: isEnemy(nf, nr) }); | |
| } | |
| } | |
| } | |
| } | |
| // Castling | |
| if (isWhite && castling.includes('K') && isEmpty(5, 0) && isEmpty(6, 0)) { | |
| moves.push({ file: 6, rank: 0, capture: false, castle: 'kingside' }); | |
| specialInfo.push("Kingside castle available (O-O)"); | |
| } | |
| if (isWhite && castling.includes('Q') && isEmpty(1, 0) && isEmpty(2, 0) && isEmpty(3, 0)) { | |
| moves.push({ file: 2, rank: 0, capture: false, castle: 'queenside' }); | |
| specialInfo.push("Queenside castle available (O-O-O)"); | |
| } | |
| if (!isWhite && castling.includes('k') && isEmpty(5, 7) && isEmpty(6, 7)) { | |
| moves.push({ file: 6, rank: 7, capture: false, castle: 'kingside' }); | |
| specialInfo.push("Kingside castle available (O-O)"); | |
| } | |
| if (!isWhite && castling.includes('q') && isEmpty(1, 7) && isEmpty(2, 7) && isEmpty(3, 7)) { | |
| moves.push({ file: 2, rank: 7, capture: false, castle: 'queenside' }); | |
| specialInfo.push("Queenside castle available (O-O-O)"); | |
| } | |
| } | |
| return { name, colorName, moves, specialInfo }; | |
| } | |
| function createHighlightSquare(file, rank, isCapture, isSpecial) { | |
| const geo = new THREE.RingGeometry(0.35, 0.45, 32); | |
| const color = isCapture ? 0xA03070 : (isSpecial ? 0xD4A020 : 0x2090B0); | |
| const mat = new THREE.MeshBasicMaterial({ | |
| color: color, | |
| transparent: true, | |
| opacity: 0.7, | |
| side: THREE.DoubleSide | |
| }); | |
| const ring = new THREE.Mesh(geo, mat); | |
| ring.position.set(file - 3.5, rank - 3.5, 0.02); | |
| return ring; | |
| } | |
| function showLegalMoves(pieceData) { | |
| // Clear existing highlights | |
| while (highlightGroup.children.length) highlightGroup.remove(highlightGroup.children[0]); | |
| const { name, colorName, moves, specialInfo } = getLegalMoves(pieceData); | |
| // Create highlight rings for each legal move | |
| for (const move of moves) { | |
| const highlight = createHighlightSquare( | |
| move.file, | |
| move.rank, | |
| move.capture, | |
| move.castle || move.enPassant | |
| ); | |
| highlightGroup.add(highlight); | |
| } | |
| // Update tooltip | |
| const tooltip = document.getElementById('piece-tooltip'); | |
| const captures = moves.filter(m => m.capture).length; | |
| const quietMoves = moves.filter(m => !m.capture).length; | |
| let html = `<div class="piece-name">${colorName} ${name}</div>`; | |
| html += `<div class="move-count">${moves.length} legal moves</div>`; | |
| if (quietMoves > 0) html += `<div>• ${quietMoves} quiet moves</div>`; | |
| if (captures > 0) html += `<div style="color:#906070">• ${captures} captures</div>`; | |
| for (const info of specialInfo) { | |
| html += `<div class="special">★ ${info}</div>`; | |
| } | |
| tooltip.innerHTML = html; | |
| tooltip.style.display = 'block'; | |
| } | |
| function hideTooltip() { | |
| document.getElementById('piece-tooltip').style.display = 'none'; | |
| while (highlightGroup.children.length) highlightGroup.remove(highlightGroup.children[0]); | |
| } | |
| function onMouseMove(event) { | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| // Update tooltip position | |
| const tooltip = document.getElementById('piece-tooltip'); | |
| tooltip.style.left = (event.clientX + 15) + 'px'; | |
| tooltip.style.top = (event.clientY + 15) + 'px'; | |
| // Raycast to find hovered piece | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObjects(piecesGroup.children, true); | |
| if (intersects.length > 0) { | |
| // Find the parent group with userData | |
| let piece = intersects[0].object; | |
| while (piece && !piece.userData?.type && piece.parent) { | |
| piece = piece.parent; | |
| } | |
| if (piece?.userData?.type) { | |
| if (hoveredPiece !== piece) { | |
| hoveredPiece = piece; | |
| showLegalMoves(piece.userData); | |
| } | |
| } else { | |
| if (hoveredPiece) { | |
| hoveredPiece = null; | |
| hideTooltip(); | |
| } | |
| } | |
| } else { | |
| if (hoveredPiece) { | |
| hoveredPiece = null; | |
| hideTooltip(); | |
| } | |
| } | |
| } | |
| function onResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Update cinematic camera if animating | |
| updateCinematicCamera(); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |