tostido's picture
Centered board layout, replay overlay, mobile responsive CSS
cb468dd
<!DOCTYPE html>
<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>