Spaces:
Running
Running
Maximus Powers
commited on
Commit
·
3568151
1
Parent(s):
3787eeb
final
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitignore +96 -0
- Dockerfile +25 -0
- README.md +8 -10
- index.html +26 -0
- nginx.conf +31 -0
- package-lock.json +0 -0
- package.json +31 -0
- public/assets/pieces/dark/rotated/Chess_Bdt45.svg +78 -0
- public/assets/pieces/dark/rotated/Chess_Ndt45.svg +22 -0
- public/assets/pieces/dark/rotated/Chess_fdt45.svg +25 -0
- public/assets/pieces/dark/rotated/Chess_gdt45.svg +157 -0
- public/assets/pieces/dark/rotated/Chess_hdt45.svg +66 -0
- public/assets/pieces/dark/rotated/Chess_mdt45.svg +133 -0
- public/assets/pieces/dark/upright/Chess_bdt45.svg +12 -0
- public/assets/pieces/dark/upright/Chess_kdt45.svg +12 -0
- public/assets/pieces/dark/upright/Chess_ndt45.svg +22 -0
- public/assets/pieces/dark/upright/Chess_pdt45.svg +5 -0
- public/assets/pieces/dark/upright/Chess_qdt45.svg +27 -0
- public/assets/pieces/dark/upright/Chess_rdt45.svg +39 -0
- public/assets/pieces/white/rotated/Chess_Blt45.svg +79 -0
- public/assets/pieces/white/rotated/Chess_Nlt45.svg +117 -0
- public/assets/pieces/white/rotated/Chess_flt45.svg +115 -0
- public/assets/pieces/white/rotated/Chess_glt45.svg +157 -0
- public/assets/pieces/white/rotated/Chess_hlt45.svg +87 -0
- public/assets/pieces/white/rotated/Chess_mlt45.svg +117 -0
- public/assets/pieces/white/upright/Chess_blt45.svg +12 -0
- public/assets/pieces/white/upright/Chess_klt45.svg +9 -0
- public/assets/pieces/white/upright/Chess_nlt45.svg +19 -0
- public/assets/pieces/white/upright/Chess_plt45.svg +5 -0
- public/assets/pieces/white/upright/Chess_qlt45.svg +15 -0
- public/assets/pieces/white/upright/Chess_rlt45.svg +25 -0
- src/App.tsx +178 -0
- src/ErrorBoundary.tsx +69 -0
- src/components/AudioInfoPopup.tsx +56 -0
- src/components/ChessBoard.tsx +169 -0
- src/components/ChessPiece.tsx +48 -0
- src/components/GameControls.tsx +152 -0
- src/components/PromotionDialog.tsx +47 -0
- src/components/VolumeSlider.tsx +43 -0
- src/engines/AudioEngine.ts +311 -0
- src/engines/ChessAI.ts +129 -0
- src/engines/PositionEvaluator.ts +443 -0
- src/hooks/useChessGame.ts +404 -0
- src/main.tsx +15 -0
- src/styles/App.css +117 -0
- src/styles/AudioInfoPopup.css +146 -0
- src/styles/ChessBoard.css +99 -0
- src/styles/ChessPiece.css +30 -0
- src/styles/ChessSquare.css +60 -0
- src/styles/GameControls.css +92 -0
.gitignore
ADDED
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Dependencies
|
2 |
+
node_modules/
|
3 |
+
/.pnp
|
4 |
+
.pnp.js
|
5 |
+
|
6 |
+
# Production
|
7 |
+
/dist
|
8 |
+
/build
|
9 |
+
|
10 |
+
# Development
|
11 |
+
.env.local
|
12 |
+
.env.development.local
|
13 |
+
.env.test.local
|
14 |
+
.env.production.local
|
15 |
+
|
16 |
+
# Logs
|
17 |
+
npm-debug.log*
|
18 |
+
yarn-debug.log*
|
19 |
+
yarn-error.log*
|
20 |
+
pnpm-debug.log*
|
21 |
+
lerna-debug.log*
|
22 |
+
|
23 |
+
# Runtime data
|
24 |
+
pids
|
25 |
+
*.pid
|
26 |
+
*.seed
|
27 |
+
*.pid.lock
|
28 |
+
|
29 |
+
# Coverage directory used by tools like istanbul
|
30 |
+
coverage/
|
31 |
+
*.lcov
|
32 |
+
|
33 |
+
# nyc test coverage
|
34 |
+
.nyc_output
|
35 |
+
|
36 |
+
# ESLint cache
|
37 |
+
.eslintcache
|
38 |
+
|
39 |
+
# Optional npm cache directory
|
40 |
+
.npm
|
41 |
+
|
42 |
+
# Optional eslint cache
|
43 |
+
.eslintcache
|
44 |
+
|
45 |
+
# Microbundle cache
|
46 |
+
.rpt2_cache/
|
47 |
+
.rts2_cache_cjs/
|
48 |
+
.rts2_cache_es/
|
49 |
+
.rts2_cache_umd/
|
50 |
+
|
51 |
+
# Optional REPL history
|
52 |
+
.node_repl_history
|
53 |
+
|
54 |
+
# Output of 'npm pack'
|
55 |
+
*.tgz
|
56 |
+
|
57 |
+
# Yarn Integrity file
|
58 |
+
.yarn-integrity
|
59 |
+
|
60 |
+
# parcel-bundler cache (https://parceljs.org/)
|
61 |
+
.cache
|
62 |
+
.parcel-cache
|
63 |
+
|
64 |
+
# Vite cache
|
65 |
+
.vite
|
66 |
+
|
67 |
+
# Next.js build output
|
68 |
+
.next
|
69 |
+
|
70 |
+
# Nuxt.js build / generate output
|
71 |
+
.nuxt
|
72 |
+
dist
|
73 |
+
|
74 |
+
# Storybook build outputs
|
75 |
+
.out
|
76 |
+
.storybook-out
|
77 |
+
|
78 |
+
# Temporary folders
|
79 |
+
tmp/
|
80 |
+
temp/
|
81 |
+
|
82 |
+
# IDE
|
83 |
+
.vscode/
|
84 |
+
.idea/
|
85 |
+
*.swp
|
86 |
+
*.swo
|
87 |
+
*~
|
88 |
+
|
89 |
+
# OS
|
90 |
+
.DS_Store
|
91 |
+
.DS_Store?
|
92 |
+
._*
|
93 |
+
.Spotlight-V100
|
94 |
+
.Trashes
|
95 |
+
ehthumbs.db
|
96 |
+
Thumbs.db
|
Dockerfile
ADDED
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
FROM python:3.9-slim
|
2 |
+
|
3 |
+
RUN useradd -m -u 1000 user
|
4 |
+
|
5 |
+
RUN apt-get update && apt-get install -y curl && \
|
6 |
+
curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
|
7 |
+
apt-get install -y nodejs && \
|
8 |
+
apt-get clean && rm -rf /var/lib/apt/lists/*
|
9 |
+
|
10 |
+
USER user
|
11 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
12 |
+
|
13 |
+
WORKDIR /app
|
14 |
+
|
15 |
+
COPY --chown=user package*.json ./
|
16 |
+
|
17 |
+
RUN npm ci
|
18 |
+
|
19 |
+
COPY --chown=user . .
|
20 |
+
|
21 |
+
RUN npm run build
|
22 |
+
|
23 |
+
EXPOSE 7860
|
24 |
+
|
25 |
+
CMD ["python", "-m", "http.server", "7860", "--directory", "dist"]
|
README.md
CHANGED
@@ -1,12 +1,10 @@
|
|
1 |
---
|
2 |
-
title: Musical Chess
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
---
|
11 |
-
|
12 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
---
|
2 |
+
title: Musical Chess Arena
|
3 |
+
emoji: 🎵♟️
|
4 |
+
colorFrom: purple
|
5 |
+
colorTo: blue
|
6 |
sdk: docker
|
7 |
+
sdk_version: "4.40.0"
|
8 |
+
app_file: app.py
|
9 |
+
pinned: true
|
10 |
+
---
|
|
|
|
index.html
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
6 |
+
<title>🎵♟️ Musical Chess Arena</title>
|
7 |
+
<style>
|
8 |
+
* {
|
9 |
+
margin: 0;
|
10 |
+
padding: 0;
|
11 |
+
box-sizing: border-box;
|
12 |
+
}
|
13 |
+
|
14 |
+
body {
|
15 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
16 |
+
background-color: #323232;
|
17 |
+
color: white;
|
18 |
+
overflow: hidden;
|
19 |
+
}
|
20 |
+
</style>
|
21 |
+
</head>
|
22 |
+
<body>
|
23 |
+
<div id="root"></div>
|
24 |
+
<script type="module" src="/src/main.tsx"></script>
|
25 |
+
</body>
|
26 |
+
</html>
|
nginx.conf
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
events {
|
2 |
+
worker_connections 1024;
|
3 |
+
}
|
4 |
+
|
5 |
+
http {
|
6 |
+
include /etc/nginx/mime.types;
|
7 |
+
default_type application/octet-stream;
|
8 |
+
|
9 |
+
server {
|
10 |
+
listen 7860;
|
11 |
+
server_name localhost;
|
12 |
+
root /usr/share/nginx/html;
|
13 |
+
index index.html;
|
14 |
+
|
15 |
+
gzip on;
|
16 |
+
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
17 |
+
|
18 |
+
location / {
|
19 |
+
try_files $uri $uri/ /index.html;
|
20 |
+
}
|
21 |
+
|
22 |
+
location /assets/ {
|
23 |
+
expires 1y;
|
24 |
+
add_header Cache-Control "public, immutable";
|
25 |
+
}
|
26 |
+
|
27 |
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
28 |
+
add_header X-Content-Type-Options "nosniff" always;
|
29 |
+
add_header X-XSS-Protection "1; mode=block" always;
|
30 |
+
}
|
31 |
+
}
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "musical-chess",
|
3 |
+
"version": "1.0.0",
|
4 |
+
"private": true,
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "tsc && vite build",
|
9 |
+
"preview": "vite preview",
|
10 |
+
"start": "python3 -m http.server 3000 --directory dist"
|
11 |
+
},
|
12 |
+
"dependencies": {
|
13 |
+
"react": "^18.2.0",
|
14 |
+
"react-dom": "^18.2.0",
|
15 |
+
"chess.js": "^1.0.0-beta.8",
|
16 |
+
"@xenova/transformers": "^2.17.2"
|
17 |
+
},
|
18 |
+
"devDependencies": {
|
19 |
+
"@types/node": "^20.10.0",
|
20 |
+
"@types/react": "^18.2.43",
|
21 |
+
"@types/react-dom": "^18.2.17",
|
22 |
+
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
23 |
+
"@typescript-eslint/parser": "^6.14.0",
|
24 |
+
"@vitejs/plugin-react": "^4.2.1",
|
25 |
+
"eslint": "^8.55.0",
|
26 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
27 |
+
"eslint-plugin-react-refresh": "^0.4.5",
|
28 |
+
"typescript": "^5.2.2",
|
29 |
+
"vite": "^5.0.8"
|
30 |
+
}
|
31 |
+
}
|
public/assets/pieces/dark/rotated/Chess_Bdt45.svg
ADDED
|
public/assets/pieces/dark/rotated/Chess_Ndt45.svg
ADDED
|
public/assets/pieces/dark/rotated/Chess_fdt45.svg
ADDED
|
public/assets/pieces/dark/rotated/Chess_gdt45.svg
ADDED
|
public/assets/pieces/dark/rotated/Chess_hdt45.svg
ADDED
|
public/assets/pieces/dark/rotated/Chess_mdt45.svg
ADDED
|
public/assets/pieces/dark/upright/Chess_bdt45.svg
ADDED
|
public/assets/pieces/dark/upright/Chess_kdt45.svg
ADDED
|
public/assets/pieces/dark/upright/Chess_ndt45.svg
ADDED
|
public/assets/pieces/dark/upright/Chess_pdt45.svg
ADDED
|
public/assets/pieces/dark/upright/Chess_qdt45.svg
ADDED
|
public/assets/pieces/dark/upright/Chess_rdt45.svg
ADDED
|
public/assets/pieces/white/rotated/Chess_Blt45.svg
ADDED
|
public/assets/pieces/white/rotated/Chess_Nlt45.svg
ADDED
|
public/assets/pieces/white/rotated/Chess_flt45.svg
ADDED
|
public/assets/pieces/white/rotated/Chess_glt45.svg
ADDED
|
public/assets/pieces/white/rotated/Chess_hlt45.svg
ADDED
|
public/assets/pieces/white/rotated/Chess_mlt45.svg
ADDED
|
public/assets/pieces/white/upright/Chess_blt45.svg
ADDED
|
public/assets/pieces/white/upright/Chess_klt45.svg
ADDED
|
public/assets/pieces/white/upright/Chess_nlt45.svg
ADDED
|
public/assets/pieces/white/upright/Chess_plt45.svg
ADDED
|
public/assets/pieces/white/upright/Chess_qlt45.svg
ADDED
|
public/assets/pieces/white/upright/Chess_rlt45.svg
ADDED
|
src/App.tsx
ADDED
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useEffect, useRef } from 'react'
|
2 |
+
import { ChessBoard } from './components/ChessBoard'
|
3 |
+
import { GameControls } from './components/GameControls'
|
4 |
+
import { PromotionDialog } from './components/PromotionDialog'
|
5 |
+
import { AudioInfoPopup } from './components/AudioInfoPopup'
|
6 |
+
import { useChessGame } from './hooks/useChessGame'
|
7 |
+
import { AudioEngine } from './engines/AudioEngine'
|
8 |
+
import './styles/App.css'
|
9 |
+
|
10 |
+
function App() {
|
11 |
+
const {
|
12 |
+
gameState,
|
13 |
+
draggedPiece,
|
14 |
+
selectedModel,
|
15 |
+
startNewGame,
|
16 |
+
resignGame,
|
17 |
+
togglePlayerColor,
|
18 |
+
selectSquare,
|
19 |
+
attemptMove,
|
20 |
+
completePromotion,
|
21 |
+
startDrag,
|
22 |
+
endDrag,
|
23 |
+
changeModel
|
24 |
+
} = useChessGame()
|
25 |
+
|
26 |
+
const [audioEnabled, setAudioEnabled] = useState(false)
|
27 |
+
const [showAudioInfo, setShowAudioInfo] = useState(false)
|
28 |
+
const [volumeSettings, setVolumeSettings] = useState<{ ambient: number, game: number}>({
|
29 |
+
ambient: 0.8, // Default even louder for ambient beats
|
30 |
+
game: 0.08 // 8% default for game sounds (scaled)
|
31 |
+
})
|
32 |
+
const audioEngineRef = useRef<AudioEngine | null>(null)
|
33 |
+
|
34 |
+
// Initialize audio engine
|
35 |
+
useEffect(() => {
|
36 |
+
audioEngineRef.current = new AudioEngine()
|
37 |
+
|
38 |
+
return () => {
|
39 |
+
if (audioEngineRef.current) {
|
40 |
+
audioEngineRef.current.cleanup()
|
41 |
+
}
|
42 |
+
}
|
43 |
+
}, [])
|
44 |
+
|
45 |
+
// Handle audio state changes
|
46 |
+
useEffect(() => {
|
47 |
+
if (audioEngineRef.current) {
|
48 |
+
if (audioEnabled) {
|
49 |
+
audioEngineRef.current.setVolume(0.7)
|
50 |
+
audioEngineRef.current.setBoardFlipped(gameState.playerColor === 'b')
|
51 |
+
if (gameState.gameActive) {
|
52 |
+
audioEngineRef.current.updatePositionAudio(gameState.board, gameState.playerColor)
|
53 |
+
}
|
54 |
+
} else {
|
55 |
+
audioEngineRef.current.setVolume(0)
|
56 |
+
audioEngineRef.current.stopAllAudio()
|
57 |
+
}
|
58 |
+
}
|
59 |
+
}, [audioEnabled, gameState.gameActive, gameState.playerColor])
|
60 |
+
|
61 |
+
// Handle move audio
|
62 |
+
useEffect(() => {
|
63 |
+
if (audioEnabled && audioEngineRef.current && gameState.gameHistory.length > 0) {
|
64 |
+
const lastMove = gameState.gameHistory[gameState.gameHistory.length - 1]
|
65 |
+
audioEngineRef.current.playMoveSound(lastMove.moveData, gameState.board, lastMove.capturedPiece)
|
66 |
+
}
|
67 |
+
}, [gameState.gameHistory.length, audioEnabled])
|
68 |
+
|
69 |
+
// Handle position audio updates
|
70 |
+
useEffect(() => {
|
71 |
+
if (audioEnabled && audioEngineRef.current && gameState.gameActive) {
|
72 |
+
audioEngineRef.current.updateInitiativeVolumes(gameState.board, gameState.playerColor)
|
73 |
+
}
|
74 |
+
}, [gameState.board.fen(), audioEnabled, gameState.gameActive, gameState.playerColor])
|
75 |
+
|
76 |
+
// Stop audio when game ends
|
77 |
+
useEffect(() => {
|
78 |
+
if (audioEngineRef.current && gameState.gameOver) {
|
79 |
+
audioEngineRef.current.stopPositionAudio()
|
80 |
+
}
|
81 |
+
}, [gameState.gameOver])
|
82 |
+
|
83 |
+
const handleStartGame = () => {
|
84 |
+
startNewGame()
|
85 |
+
|
86 |
+
// Enable audio context on user interaction
|
87 |
+
if (audioEnabled && audioEngineRef.current) {
|
88 |
+
audioEngineRef.current.ensureAudioContext()
|
89 |
+
}
|
90 |
+
}
|
91 |
+
|
92 |
+
const handleToggleAudio = () => {
|
93 |
+
setAudioEnabled(!audioEnabled)
|
94 |
+
|
95 |
+
// Enable audio context on user interaction if turning on
|
96 |
+
if (!audioEnabled && audioEngineRef.current) {
|
97 |
+
audioEngineRef.current.ensureAudioContext()
|
98 |
+
}
|
99 |
+
}
|
100 |
+
|
101 |
+
const handleVolumeChange = (type: "ambient" | "game", value: number) => {
|
102 |
+
setVolumeSettings(prev => ({
|
103 |
+
...prev,
|
104 |
+
[type]: value
|
105 |
+
}))
|
106 |
+
|
107 |
+
// Update audio engine with new volume
|
108 |
+
if (audioEngineRef.current) {
|
109 |
+
switch (type) {
|
110 |
+
case 'ambient':
|
111 |
+
audioEngineRef.current.setAmbientVolume(value)
|
112 |
+
break
|
113 |
+
case 'game':
|
114 |
+
audioEngineRef.current.setGameVolume(value)
|
115 |
+
break
|
116 |
+
}
|
117 |
+
}
|
118 |
+
}
|
119 |
+
|
120 |
+
return (
|
121 |
+
<div className="app-container">
|
122 |
+
<div className="main-content">
|
123 |
+
<div className="header">
|
124 |
+
<h1 className="title">🎵♟️ Musical Chess</h1>
|
125 |
+
<button
|
126 |
+
className="audio-info-button"
|
127 |
+
onClick={() => setShowAudioInfo(true)}
|
128 |
+
title="Audio Guide"
|
129 |
+
>
|
130 |
+
ℹ️
|
131 |
+
</button>
|
132 |
+
</div>
|
133 |
+
|
134 |
+
<div className="game-area">
|
135 |
+
<div className="board-container">
|
136 |
+
<ChessBoard
|
137 |
+
key={gameState.board.fen()}
|
138 |
+
gameState={gameState}
|
139 |
+
draggedPiece={draggedPiece}
|
140 |
+
audioEngine={audioEngineRef.current}
|
141 |
+
onSquareClick={selectSquare}
|
142 |
+
onPieceDragStart={startDrag}
|
143 |
+
onPieceDrop={endDrag}
|
144 |
+
/>
|
145 |
+
</div>
|
146 |
+
|
147 |
+
<div className="sidebar">
|
148 |
+
<GameControls
|
149 |
+
gameState={gameState}
|
150 |
+
audioEnabled={audioEnabled}
|
151 |
+
volumeSettings={volumeSettings}
|
152 |
+
selectedModel={selectedModel}
|
153 |
+
onStartGame={handleStartGame}
|
154 |
+
onResignGame={resignGame}
|
155 |
+
onToggleColor={togglePlayerColor}
|
156 |
+
onToggleAudio={handleToggleAudio}
|
157 |
+
onVolumeChange={handleVolumeChange}
|
158 |
+
onModelChange={changeModel}
|
159 |
+
/>
|
160 |
+
</div>
|
161 |
+
</div>
|
162 |
+
</div>
|
163 |
+
|
164 |
+
<PromotionDialog
|
165 |
+
isVisible={gameState.promotionDialogActive}
|
166 |
+
color={gameState.playerColor}
|
167 |
+
onSelect={completePromotion}
|
168 |
+
/>
|
169 |
+
|
170 |
+
<AudioInfoPopup
|
171 |
+
isVisible={showAudioInfo}
|
172 |
+
onClose={() => setShowAudioInfo(false)}
|
173 |
+
/>
|
174 |
+
</div>
|
175 |
+
)
|
176 |
+
}
|
177 |
+
|
178 |
+
export default App
|
src/ErrorBoundary.tsx
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
|
3 |
+
export class ErrorBoundary extends React.Component<{
|
4 |
+
children: React.ReactNode
|
5 |
+
},{
|
6 |
+
hasError: boolean
|
7 |
+
error?: Error
|
8 |
+
} > {
|
9 |
+
constructor(props: {
|
10 |
+
children: React.ReactNode
|
11 |
+
}) {
|
12 |
+
super(props)
|
13 |
+
this.state = { hasError: false }
|
14 |
+
}
|
15 |
+
|
16 |
+
static getDerivedStateFromError(error: Error): {
|
17 |
+
hasError: boolean
|
18 |
+
error?: Error
|
19 |
+
} {
|
20 |
+
return { hasError: true, error }
|
21 |
+
}
|
22 |
+
|
23 |
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
24 |
+
console.error('Error caught by boundary:', error, errorInfo)
|
25 |
+
}
|
26 |
+
|
27 |
+
render() {
|
28 |
+
if (this.state.hasError) {
|
29 |
+
return (
|
30 |
+
<div style={{
|
31 |
+
padding: '20px',
|
32 |
+
backgroundColor: '#323232',
|
33 |
+
color: 'white',
|
34 |
+
minHeight: '100vh'
|
35 |
+
}}>
|
36 |
+
<h1>🎵♟️ Musical Chess Arena</h1>
|
37 |
+
<div style={{
|
38 |
+
backgroundColor: '#ff4444',
|
39 |
+
padding: '20px',
|
40 |
+
borderRadius: '8px',
|
41 |
+
marginTop: '20px'
|
42 |
+
}}>
|
43 |
+
<h2>Something went wrong!</h2>
|
44 |
+
<p>Error: {this.state.error?.message}</p>
|
45 |
+
<pre style={{ marginTop: '10px', fontSize: '12px' }}>
|
46 |
+
{this.state.error?.stack}
|
47 |
+
</pre>
|
48 |
+
<button
|
49 |
+
onClick={() => window.location.reload()}
|
50 |
+
style={{
|
51 |
+
marginTop: '20px',
|
52 |
+
padding: '10px 20px',
|
53 |
+
backgroundColor: '#323232',
|
54 |
+
color: 'white',
|
55 |
+
border: '2px solid white',
|
56 |
+
borderRadius: '4px',
|
57 |
+
cursor: 'pointer'
|
58 |
+
}}
|
59 |
+
>
|
60 |
+
Reload Page
|
61 |
+
</button>
|
62 |
+
</div>
|
63 |
+
</div>
|
64 |
+
)
|
65 |
+
}
|
66 |
+
|
67 |
+
return this.props.children
|
68 |
+
}
|
69 |
+
}
|
src/components/AudioInfoPopup.tsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import '../styles/AudioInfoPopup.css'
|
3 |
+
|
4 |
+
export const AudioInfoPopup: React.FC<{
|
5 |
+
isVisible: boolean
|
6 |
+
onClose: () => void
|
7 |
+
}> = ({
|
8 |
+
isVisible,
|
9 |
+
onClose
|
10 |
+
}) => {
|
11 |
+
if (!isVisible) return null
|
12 |
+
|
13 |
+
return (
|
14 |
+
<div className="audio-info-overlay" onClick={onClose}>
|
15 |
+
<div className="audio-info-popup" onClick={(e) => e.stopPropagation()}>
|
16 |
+
<div className="popup-header">
|
17 |
+
<h2>🎵 How muscial chess works</h2>
|
18 |
+
<button className="close-button" onClick={onClose}>×</button>
|
19 |
+
</div>
|
20 |
+
|
21 |
+
<div className="popup-content">
|
22 |
+
<section>
|
23 |
+
<h3>🎯 Initiative Tempo</h3>
|
24 |
+
<p>A steady beat represents your position strength:</p>
|
25 |
+
<ul>
|
26 |
+
<li><strong>Frequency:</strong> A2 (110 Hz)</li>
|
27 |
+
<li><strong>Tempo:</strong> Speed increases with your initiative (up to 120 BPM). No beat = no initiative. Faster beat = stronger position.</li>
|
28 |
+
</ul>
|
29 |
+
</section>
|
30 |
+
|
31 |
+
<section>
|
32 |
+
<h3>🎼 Square-Based Move Notes</h3>
|
33 |
+
<p>Each square has a unique musical note based on its position:</p>
|
34 |
+
<div className="note-mapping">
|
35 |
+
<li><strong>Files (left to right):</strong> A, B, C♭, C, D, D♭, E, F</li>
|
36 |
+
<li><strong>Ranks (bottom to top):</strong> 8 octaves (1st-8th)</li>
|
37 |
+
</div>
|
38 |
+
<p>Every move plays two quick beats:</p>
|
39 |
+
<ul>
|
40 |
+
<li><strong>First beat:</strong> Origin square note.</li>
|
41 |
+
<li><strong>Second beat:</strong> Destination square note.</li>
|
42 |
+
</ul>
|
43 |
+
</section>
|
44 |
+
|
45 |
+
<section>
|
46 |
+
<h3>⚡ Special Move Notes</h3>
|
47 |
+
<ul>
|
48 |
+
<li><strong>Capture:</strong> Pop sound.</li>
|
49 |
+
<li><strong>Danger:</strong> Sharp tone (1000 Hz) when piece threatens capture.</li>
|
50 |
+
</ul>
|
51 |
+
</section>
|
52 |
+
</div>
|
53 |
+
</div>
|
54 |
+
</div>
|
55 |
+
)
|
56 |
+
}
|
src/components/ChessBoard.tsx
ADDED
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useCallback, useState } from 'react'
|
2 |
+
import { Square } from 'chess.js'
|
3 |
+
import { ChessPiece } from './ChessPiece'
|
4 |
+
import { GameState, DraggedPiece } from '../types/chess'
|
5 |
+
import { isSquareLight } from '../utils/chessUtils'
|
6 |
+
import { AudioEngine } from '../engines/AudioEngine'
|
7 |
+
import '../styles/ChessBoard.css'
|
8 |
+
import '../styles/ChessSquare.css'
|
9 |
+
|
10 |
+
export const ChessBoard: React.FC<{
|
11 |
+
gameState: GameState
|
12 |
+
draggedPiece: DraggedPiece | null
|
13 |
+
audioEngine: AudioEngine | null
|
14 |
+
onSquareClick: (square: Square) => void
|
15 |
+
onPieceDragStart: (square: Square) => void
|
16 |
+
onPieceDrop: (targetSquare: Square | null) => void
|
17 |
+
}> = ({
|
18 |
+
gameState,
|
19 |
+
draggedPiece,
|
20 |
+
audioEngine,
|
21 |
+
onSquareClick,
|
22 |
+
onPieceDragStart,
|
23 |
+
onPieceDrop
|
24 |
+
}) => {
|
25 |
+
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
|
26 |
+
const [isDragging, setIsDragging] = useState(false)
|
27 |
+
|
28 |
+
const handleMouseMove = useCallback((event: React.MouseEvent) => {
|
29 |
+
setMousePosition({ x: event.clientX, y: event.clientY })
|
30 |
+
}, [])
|
31 |
+
|
32 |
+
const handleSquareClick = useCallback((square: Square) => {
|
33 |
+
if (!isDragging) {
|
34 |
+
onSquareClick(square)
|
35 |
+
}
|
36 |
+
}, [onSquareClick, isDragging])
|
37 |
+
|
38 |
+
const handleMouseDown = useCallback((square: Square) => {
|
39 |
+
const piece = gameState.board.get(square)
|
40 |
+
if (piece && piece.color === gameState.playerColor) {
|
41 |
+
setIsDragging(true)
|
42 |
+
onPieceDragStart(square)
|
43 |
+
}
|
44 |
+
}, [gameState, onPieceDragStart])
|
45 |
+
|
46 |
+
const handleMouseUp = useCallback((square: Square) => {
|
47 |
+
if (isDragging) {
|
48 |
+
onPieceDrop(square)
|
49 |
+
setIsDragging(false)
|
50 |
+
}
|
51 |
+
}, [isDragging, onPieceDrop])
|
52 |
+
|
53 |
+
const renderSquares = () => {
|
54 |
+
const renderedSquares = []
|
55 |
+
|
56 |
+
for (let rank = 8; rank >= 1; rank--) {
|
57 |
+
for (let file = 0; file < 8; file++) {
|
58 |
+
const square = (String.fromCharCode(97 + file) + rank) as Square
|
59 |
+
|
60 |
+
let displayFile = file
|
61 |
+
let displayRank = rank
|
62 |
+
|
63 |
+
if (gameState.playerColor === 'b') {
|
64 |
+
displayFile = 7 - file
|
65 |
+
displayRank = 9 - rank
|
66 |
+
}
|
67 |
+
|
68 |
+
const piece = gameState.board.get(square) || null
|
69 |
+
const isSelected = gameState.selectedSquare === square
|
70 |
+
const isLegalMove = gameState.legalMoves.some(move => move.to === square)
|
71 |
+
const isDraggedSquare = draggedPiece?.square === square
|
72 |
+
const isLight = isSquareLight(square)
|
73 |
+
|
74 |
+
renderedSquares.push(
|
75 |
+
<div
|
76 |
+
key={square}
|
77 |
+
className={`chess-square ${isLight ? 'light' : 'dark'} ${isSelected ? 'selected' : ''} ${isLegalMove ? 'legal-move' : ''}`}
|
78 |
+
style={{
|
79 |
+
position: 'absolute',
|
80 |
+
left: displayFile * 75,
|
81 |
+
top: (8 - displayRank) * 75,
|
82 |
+
width: 75,
|
83 |
+
height: 75
|
84 |
+
}}
|
85 |
+
onClick={() => handleSquareClick(square)}
|
86 |
+
onMouseDown={() => handleMouseDown(square)}
|
87 |
+
onMouseUp={() => handleMouseUp(square)}
|
88 |
+
>
|
89 |
+
{piece && !isDraggedSquare && (
|
90 |
+
<div className="piece-container">
|
91 |
+
<ChessPiece piece={piece} size={75} />
|
92 |
+
</div>
|
93 |
+
)}
|
94 |
+
|
95 |
+
{isLegalMove && (
|
96 |
+
<div className="legal-move-indicator" />
|
97 |
+
)}
|
98 |
+
</div>
|
99 |
+
)
|
100 |
+
}
|
101 |
+
}
|
102 |
+
|
103 |
+
return renderedSquares
|
104 |
+
}
|
105 |
+
|
106 |
+
const renderDraggedPiece = () => {
|
107 |
+
if (!draggedPiece) return null
|
108 |
+
|
109 |
+
return (
|
110 |
+
<div
|
111 |
+
className="dragged-piece"
|
112 |
+
style={{
|
113 |
+
left: mousePosition.x - 37.5,
|
114 |
+
top: mousePosition.y - 37.5,
|
115 |
+
position: 'fixed',
|
116 |
+
pointerEvents: 'none',
|
117 |
+
zIndex: 1000
|
118 |
+
}}
|
119 |
+
>
|
120 |
+
<ChessPiece
|
121 |
+
piece={draggedPiece.piece}
|
122 |
+
size={75}
|
123 |
+
/>
|
124 |
+
</div>
|
125 |
+
)
|
126 |
+
}
|
127 |
+
|
128 |
+
return (
|
129 |
+
<div
|
130 |
+
className="chess-board"
|
131 |
+
onMouseMove={handleMouseMove}
|
132 |
+
>
|
133 |
+
<div className="board-container">
|
134 |
+
<div className="board-squares">
|
135 |
+
{renderSquares()}
|
136 |
+
</div>
|
137 |
+
|
138 |
+
<div className="board-border" />
|
139 |
+
<div className="file-labels">
|
140 |
+
{Array.from({ length: 8 }, (_, i) => {
|
141 |
+
const fileIndex = gameState.playerColor === 'w' ? i : 7 - i
|
142 |
+
const fileLabel = String.fromCharCode(97 + fileIndex)
|
143 |
+
const noteName = audioEngine?.getFileNoteName(fileIndex) || ''
|
144 |
+
return (
|
145 |
+
<div key={fileLabel} className="file-label">
|
146 |
+
<span className="file-letter">{fileLabel}</span>
|
147 |
+
<span className="file-note">({noteName})</span>
|
148 |
+
</div>
|
149 |
+
)
|
150 |
+
})}
|
151 |
+
</div>
|
152 |
+
<div className="rank-labels">
|
153 |
+
{Array.from({ length: 8 }, (_, i) => {
|
154 |
+
const rankIndex = gameState.playerColor === 'w' ? 8 - i : i + 1
|
155 |
+
const octave = gameState.playerColor === 'w' ? 8 - i : i + 1
|
156 |
+
return (
|
157 |
+
<div key={rankIndex} className="rank-label">
|
158 |
+
<span className="rank-number">{rankIndex}</span>
|
159 |
+
<span className="rank-octave">(♪{octave})</span>
|
160 |
+
</div>
|
161 |
+
)
|
162 |
+
})}
|
163 |
+
</div>
|
164 |
+
</div>
|
165 |
+
|
166 |
+
{renderDraggedPiece()}
|
167 |
+
</div>
|
168 |
+
)
|
169 |
+
}
|
src/components/ChessPiece.tsx
ADDED
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import { ChessPiece as ChessPieceType } from '../types/chess'
|
3 |
+
import '../styles/ChessPiece.css'
|
4 |
+
|
5 |
+
export const ChessPiece: React.FC<{
|
6 |
+
piece: ChessPieceType
|
7 |
+
size: number
|
8 |
+
}> = ({
|
9 |
+
piece,
|
10 |
+
size,
|
11 |
+
}) => {
|
12 |
+
|
13 |
+
const getPieceImagePath = () => {
|
14 |
+
const color = piece.color === 'w' ? 'white' : 'dark'
|
15 |
+
const orientation = 'upright'
|
16 |
+
|
17 |
+
const pieceFileMap: { [key: string]: string } = {
|
18 |
+
'k': piece.color === 'w' ? 'Chess_klt45.svg' : 'Chess_kdt45.svg',
|
19 |
+
'q': piece.color === 'w' ? 'Chess_qlt45.svg' : 'Chess_qdt45.svg',
|
20 |
+
'r': piece.color === 'w' ? 'Chess_rlt45.svg' : 'Chess_rdt45.svg',
|
21 |
+
'b': piece.color === 'w' ? 'Chess_blt45.svg' : 'Chess_bdt45.svg',
|
22 |
+
'n': piece.color === 'w' ? 'Chess_nlt45.svg' : 'Chess_ndt45.svg',
|
23 |
+
'p': piece.color === 'w' ? 'Chess_plt45.svg' : 'Chess_pdt45.svg'
|
24 |
+
}
|
25 |
+
|
26 |
+
const fileName = pieceFileMap[piece.type]
|
27 |
+
return `/assets/pieces/${color}/${orientation}/${fileName}`
|
28 |
+
}
|
29 |
+
|
30 |
+
return (
|
31 |
+
<div
|
32 |
+
className="chess-piece"
|
33 |
+
style={{
|
34 |
+
width: size,
|
35 |
+
height: size
|
36 |
+
}}
|
37 |
+
>
|
38 |
+
<img
|
39 |
+
src={getPieceImagePath()}
|
40 |
+
alt={`${piece.color === 'w' ? 'White' : 'Black'} ${piece.type}`}
|
41 |
+
width={size}
|
42 |
+
height={size}
|
43 |
+
className="piece-image"
|
44 |
+
draggable={false}
|
45 |
+
/>
|
46 |
+
</div>
|
47 |
+
)
|
48 |
+
}
|
src/components/GameControls.tsx
ADDED
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import { GameState, CHESS_MODELS } from '../types/chess'
|
3 |
+
import { VolumeSlider } from './VolumeSlider'
|
4 |
+
import '../styles/GameControls.css'
|
5 |
+
|
6 |
+
export const GameControls: React.FC<{
|
7 |
+
gameState: GameState
|
8 |
+
audioEnabled: boolean
|
9 |
+
volumeSettings: {
|
10 |
+
ambient: number
|
11 |
+
game: number
|
12 |
+
}
|
13 |
+
selectedModel: string
|
14 |
+
onStartGame: () => void
|
15 |
+
onResignGame: () => void
|
16 |
+
onToggleColor: () => void
|
17 |
+
onToggleAudio: () => void
|
18 |
+
onVolumeChange: (type: "ambient" | "game", value: number) => void
|
19 |
+
onModelChange: (modelId: string) => void
|
20 |
+
}> = ({
|
21 |
+
gameState,
|
22 |
+
audioEnabled,
|
23 |
+
volumeSettings,
|
24 |
+
selectedModel,
|
25 |
+
onStartGame,
|
26 |
+
onResignGame,
|
27 |
+
onToggleColor,
|
28 |
+
onToggleAudio,
|
29 |
+
onVolumeChange,
|
30 |
+
onModelChange
|
31 |
+
}) => {
|
32 |
+
const getMainButtonText = () => {
|
33 |
+
if (gameState.gameActive) {
|
34 |
+
return '❌ Resign Game'
|
35 |
+
}
|
36 |
+
return '🎮 Start Game'
|
37 |
+
}
|
38 |
+
|
39 |
+
const getMainButtonAction = () => {
|
40 |
+
if (gameState.gameActive) {
|
41 |
+
return onResignGame
|
42 |
+
}
|
43 |
+
return onStartGame
|
44 |
+
}
|
45 |
+
|
46 |
+
const getColorButtonText = () => {
|
47 |
+
return `⚪ Play as ${gameState.playerColor === 'w' ? 'White' : 'Black'}`
|
48 |
+
}
|
49 |
+
|
50 |
+
const getAudioButtonText = () => {
|
51 |
+
return `🔊 Audio: ${audioEnabled ? 'ON' : 'OFF'}`
|
52 |
+
}
|
53 |
+
|
54 |
+
const getGameStatus = () => {
|
55 |
+
if (gameState.aiModelLoading) {
|
56 |
+
return (
|
57 |
+
<div className="thinking-indicator">
|
58 |
+
Loading AI model<span className="thinking-dots"></span>
|
59 |
+
</div>
|
60 |
+
)
|
61 |
+
}
|
62 |
+
|
63 |
+
if (gameState.gameActive) {
|
64 |
+
if (gameState.aiThinking) {
|
65 |
+
return (
|
66 |
+
<div className="thinking-indicator">
|
67 |
+
AI is thinking<span className="thinking-dots"></span>
|
68 |
+
</div>
|
69 |
+
)
|
70 |
+
} else if (gameState.board.turn() === gameState.playerColor) {
|
71 |
+
return `Your turn (${gameState.playerColor === 'w' ? 'White' : 'Black'})`
|
72 |
+
} else {
|
73 |
+
return `AI turn (${gameState.playerColor === 'w' ? 'Black' : 'White'})`
|
74 |
+
}
|
75 |
+
} else if (gameState.gameResult?.isGameOver) {
|
76 |
+
return gameState.gameResult.message
|
77 |
+
} else {
|
78 |
+
const aiStatusText = gameState.aiModelLoaded ? '' : ' (AI: Fallback mode)'
|
79 |
+
return `Click 'Start Game' to begin${aiStatusText}`
|
80 |
+
}
|
81 |
+
}
|
82 |
+
|
83 |
+
return (
|
84 |
+
<div className="game-controls">
|
85 |
+
<div className="game-status">
|
86 |
+
{getGameStatus()}
|
87 |
+
</div>
|
88 |
+
|
89 |
+
<div className="control-buttons">
|
90 |
+
<button
|
91 |
+
className="button main-button"
|
92 |
+
onClick={getMainButtonAction()}
|
93 |
+
>
|
94 |
+
{getMainButtonText()}
|
95 |
+
</button>
|
96 |
+
|
97 |
+
{!gameState.gameActive && (
|
98 |
+
<>
|
99 |
+
<button
|
100 |
+
className="button color-button"
|
101 |
+
onClick={onToggleColor}
|
102 |
+
>
|
103 |
+
{getColorButtonText()}
|
104 |
+
</button>
|
105 |
+
|
106 |
+
<div className="model-selector">
|
107 |
+
<label htmlFor="model-select">🤖 Chess Model:</label>
|
108 |
+
<select
|
109 |
+
id="model-select"
|
110 |
+
className="model-dropdown"
|
111 |
+
value={selectedModel}
|
112 |
+
onChange={(e) => onModelChange(e.target.value)}
|
113 |
+
disabled={gameState.aiModelLoading}
|
114 |
+
>
|
115 |
+
{CHESS_MODELS.map((model) => (
|
116 |
+
<option key={model} value={model}>
|
117 |
+
{model}
|
118 |
+
</option>
|
119 |
+
))}
|
120 |
+
</select>
|
121 |
+
</div>
|
122 |
+
</>
|
123 |
+
)}
|
124 |
+
|
125 |
+
<button
|
126 |
+
className="button audio-button"
|
127 |
+
onClick={onToggleAudio}
|
128 |
+
>
|
129 |
+
{getAudioButtonText()}
|
130 |
+
</button>
|
131 |
+
</div>
|
132 |
+
|
133 |
+
{audioEnabled && (
|
134 |
+
<div className="volume-controls">
|
135 |
+
<h3 className="volume-controls-title">Audio Volume</h3>
|
136 |
+
|
137 |
+
<VolumeSlider
|
138 |
+
label="🎵 Initative Tempo"
|
139 |
+
value={volumeSettings.ambient}
|
140 |
+
onChange={(value) => onVolumeChange('ambient', value)}
|
141 |
+
/>
|
142 |
+
|
143 |
+
<VolumeSlider
|
144 |
+
label="♟️ Move Notes"
|
145 |
+
value={volumeSettings.game}
|
146 |
+
onChange={(value) => onVolumeChange('game', value)}
|
147 |
+
/>
|
148 |
+
</div>
|
149 |
+
)}
|
150 |
+
</div>
|
151 |
+
)
|
152 |
+
}
|
src/components/PromotionDialog.tsx
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import { Color, PieceSymbol } from 'chess.js'
|
3 |
+
import { ChessPiece } from './ChessPiece'
|
4 |
+
import '../styles/PromotionDialog.css'
|
5 |
+
|
6 |
+
export const PromotionDialog: React.FC<{
|
7 |
+
isVisible: boolean
|
8 |
+
color: Color
|
9 |
+
onSelect: (piece: 'q' | 'r' | 'b' | 'n') => void
|
10 |
+
}> = ({
|
11 |
+
isVisible,
|
12 |
+
color,
|
13 |
+
onSelect
|
14 |
+
}) => {
|
15 |
+
if (!isVisible) return null
|
16 |
+
|
17 |
+
const promotionPieces: Array<{ type: 'q' | 'r' | 'b' | 'n'; symbol: string }> = [
|
18 |
+
{ type: 'q', symbol: color === 'w' ? '♕' : '♛' },
|
19 |
+
{ type: 'r', symbol: color === 'w' ? '♖' : '♜' },
|
20 |
+
{ type: 'b', symbol: color === 'w' ? '♗' : '♝' },
|
21 |
+
{ type: 'n', symbol: color === 'w' ? '♘' : '♞' }
|
22 |
+
]
|
23 |
+
|
24 |
+
return (
|
25 |
+
<div className="promotion-dialog-overlay">
|
26 |
+
<div className="promotion-dialog">
|
27 |
+
<div className="promotion-dialog-title">
|
28 |
+
Choose Promotion Piece
|
29 |
+
</div>
|
30 |
+
<div className="promotion-buttons">
|
31 |
+
{promotionPieces.map(({ type, symbol }) => (
|
32 |
+
<button
|
33 |
+
key={type}
|
34 |
+
className="promotion-button"
|
35 |
+
onClick={() => onSelect(type)}
|
36 |
+
>
|
37 |
+
<ChessPiece
|
38 |
+
piece={{ type: type as PieceSymbol, color }}
|
39 |
+
size={48}
|
40 |
+
/>
|
41 |
+
</button>
|
42 |
+
))}
|
43 |
+
</div>
|
44 |
+
</div>
|
45 |
+
</div>
|
46 |
+
)
|
47 |
+
}
|
src/components/VolumeSlider.tsx
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import '../styles/VolumeSlider.css'
|
3 |
+
|
4 |
+
export const VolumeSlider: React.FC<{
|
5 |
+
label: string
|
6 |
+
value: number
|
7 |
+
onChange: (value: number) => void
|
8 |
+
min?: number
|
9 |
+
max?: number
|
10 |
+
step?: number
|
11 |
+
}> = ({
|
12 |
+
label,
|
13 |
+
value,
|
14 |
+
onChange,
|
15 |
+
min = 0,
|
16 |
+
max = 1,
|
17 |
+
step = 0.01
|
18 |
+
}) => {
|
19 |
+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
20 |
+
const newValue = parseFloat(event.target.value)
|
21 |
+
onChange(newValue)
|
22 |
+
}
|
23 |
+
|
24 |
+
const percentage = Math.round((value / max) * 100)
|
25 |
+
|
26 |
+
return (
|
27 |
+
<div className="volume-slider">
|
28 |
+
<div className="volume-slider-header">
|
29 |
+
<label className="volume-slider-label">{label}</label>
|
30 |
+
<span className="volume-slider-value">{percentage}%</span>
|
31 |
+
</div>
|
32 |
+
<input
|
33 |
+
type="range"
|
34 |
+
className="volume-slider-input"
|
35 |
+
min={min}
|
36 |
+
max={max}
|
37 |
+
step={step}
|
38 |
+
value={value}
|
39 |
+
onChange={handleChange}
|
40 |
+
/>
|
41 |
+
</div>
|
42 |
+
)
|
43 |
+
}
|
src/engines/AudioEngine.ts
ADDED
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Chess, Square, Move, PieceSymbol, Color } from 'chess.js'
|
2 |
+
import { PositionEvaluator } from './PositionEvaluator'
|
3 |
+
|
4 |
+
export class AudioEngine {
|
5 |
+
private audioContext: AudioContext | null = null
|
6 |
+
private masterGain: GainNode | null = null
|
7 |
+
private isActive: boolean = false
|
8 |
+
private volume: number = 0.7
|
9 |
+
private ambientVolume: number = 0.8
|
10 |
+
private gameVolume: number = 0.048
|
11 |
+
private userInterval: NodeJS.Timeout | null = null
|
12 |
+
private boardFlipped: boolean = false
|
13 |
+
|
14 |
+
private noteFrequencies = {
|
15 |
+
'A': 27.50, // A0
|
16 |
+
'B': 30.87, // B0
|
17 |
+
'Cb': 32.70, // C1♭
|
18 |
+
'C': 32.70, // C1
|
19 |
+
'D': 36.71, // D1
|
20 |
+
'Db': 34.65, // D1♭
|
21 |
+
'E': 41.20, // E1
|
22 |
+
'F': 43.65 // F1
|
23 |
+
}
|
24 |
+
private fileNotes = ['A', 'B', 'Cb', 'C', 'D', 'Db', 'E', 'F']
|
25 |
+
|
26 |
+
private fileFrequencies = [
|
27 |
+
this.noteFrequencies.A,
|
28 |
+
this.noteFrequencies.B,
|
29 |
+
this.noteFrequencies.Cb,
|
30 |
+
this.noteFrequencies.C,
|
31 |
+
this.noteFrequencies.D,
|
32 |
+
this.noteFrequencies.Db,
|
33 |
+
this.noteFrequencies.E,
|
34 |
+
this.noteFrequencies.F
|
35 |
+
]
|
36 |
+
private userInitiativeFreq = 110.0 // A2
|
37 |
+
|
38 |
+
constructor() {
|
39 |
+
this.initialize()
|
40 |
+
}
|
41 |
+
|
42 |
+
private async initialize() {
|
43 |
+
try {
|
44 |
+
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
45 |
+
this.masterGain = this.audioContext.createGain()
|
46 |
+
this.masterGain.connect(this.audioContext.destination)
|
47 |
+
this.masterGain.gain.value = this.volume
|
48 |
+
this.isActive = true
|
49 |
+
console.log('Audio engine initialized')
|
50 |
+
} catch (error) {
|
51 |
+
console.error('Failed to initialize audio engine:', error)
|
52 |
+
this.isActive = false
|
53 |
+
}
|
54 |
+
}
|
55 |
+
|
56 |
+
setBoardFlipped(flipped: boolean) {
|
57 |
+
this.boardFlipped = flipped
|
58 |
+
}
|
59 |
+
|
60 |
+
private getSquareFrequency(square: Square): number {
|
61 |
+
const file = square.charCodeAt(0) - 97
|
62 |
+
const rank = parseInt(square[1]) - 1
|
63 |
+
const actualFile = this.boardFlipped ? 7 - file : file
|
64 |
+
const actualRank = this.boardFlipped ? 7 - rank : rank
|
65 |
+
const baseFreq = this.fileFrequencies[actualFile]
|
66 |
+
const octaveMultiplier = Math.pow(2, actualRank)
|
67 |
+
return baseFreq * octaveMultiplier
|
68 |
+
}
|
69 |
+
|
70 |
+
getFileNoteName(fileIndex: number): string {
|
71 |
+
const actualFile = this.boardFlipped ? 7 - fileIndex : fileIndex
|
72 |
+
return this.fileNotes[actualFile]
|
73 |
+
}
|
74 |
+
|
75 |
+
setVolume(volume: number) {
|
76 |
+
this.volume = Math.max(0, Math.min(1, volume))
|
77 |
+
if (this.masterGain) {
|
78 |
+
this.masterGain.gain.value = this.volume
|
79 |
+
}
|
80 |
+
if (this.volume === 0) {
|
81 |
+
this.stopAllAudio()
|
82 |
+
}
|
83 |
+
}
|
84 |
+
|
85 |
+
setAmbientVolume(volume: number) {
|
86 |
+
this.ambientVolume = Math.max(0, Math.min(1, volume))
|
87 |
+
}
|
88 |
+
|
89 |
+
setGameVolume(volume: number) {
|
90 |
+
this.gameVolume = Math.max(0, Math.min(0.1, volume * 0.1))
|
91 |
+
}
|
92 |
+
|
93 |
+
getAmbientVolume(): number {
|
94 |
+
return this.ambientVolume
|
95 |
+
}
|
96 |
+
|
97 |
+
getGameVolume(): number {
|
98 |
+
return this.gameVolume / 0.1
|
99 |
+
}
|
100 |
+
|
101 |
+
async ensureAudioContext() {
|
102 |
+
if (!this.audioContext) {
|
103 |
+
await this.initialize()
|
104 |
+
}
|
105 |
+
|
106 |
+
if (this.audioContext?.state === 'suspended') {
|
107 |
+
await this.audioContext.resume()
|
108 |
+
}
|
109 |
+
}
|
110 |
+
|
111 |
+
private createTone(frequency: number, duration: number, fadeOut: boolean = true, volume: number = 0.4): Promise<void> {
|
112 |
+
return new Promise((resolve) => {
|
113 |
+
if (!this.audioContext || !this.masterGain || this.volume === 0) {
|
114 |
+
resolve()
|
115 |
+
return
|
116 |
+
}
|
117 |
+
|
118 |
+
const oscillator = this.audioContext.createOscillator()
|
119 |
+
const gainNode = this.audioContext.createGain()
|
120 |
+
|
121 |
+
oscillator.connect(gainNode)
|
122 |
+
gainNode.connect(this.masterGain)
|
123 |
+
|
124 |
+
oscillator.frequency.value = frequency
|
125 |
+
oscillator.type = 'sine'
|
126 |
+
|
127 |
+
const now = this.audioContext.currentTime
|
128 |
+
gainNode.gain.setValueAtTime(volume, now)
|
129 |
+
|
130 |
+
if (fadeOut) {
|
131 |
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
|
132 |
+
} else {
|
133 |
+
gainNode.gain.setValueAtTime(volume, now + duration - 0.01)
|
134 |
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
|
135 |
+
}
|
136 |
+
|
137 |
+
oscillator.start(now)
|
138 |
+
oscillator.stop(now + duration)
|
139 |
+
|
140 |
+
oscillator.onended = () => resolve()
|
141 |
+
})
|
142 |
+
}
|
143 |
+
|
144 |
+
async playMoveSound(move: Move, board: Chess, capturedPiece?: any) {
|
145 |
+
if (!this.isActive || this.volume === 0) return
|
146 |
+
|
147 |
+
try {
|
148 |
+
await this.ensureAudioContext()
|
149 |
+
const originFreq = this.getSquareFrequency(move.from as Square)
|
150 |
+
const destFreq = this.getSquareFrequency(move.to as Square)
|
151 |
+
const beatDuration = 0.2
|
152 |
+
const pauseDuration = 0.08
|
153 |
+
await this.createTone(originFreq, beatDuration, false, this.gameVolume)
|
154 |
+
await new Promise(resolve => setTimeout(resolve, pauseDuration * 1000))
|
155 |
+
await this.createTone(destFreq, beatDuration, false, this.gameVolume)
|
156 |
+
if (capturedPiece) {
|
157 |
+
await new Promise(resolve => setTimeout(resolve, pauseDuration * 1000))
|
158 |
+
await this.createCaptureSound()
|
159 |
+
} else {
|
160 |
+
const canCapture = this.checkForDanger(move.to as Square, board)
|
161 |
+
if (canCapture) {
|
162 |
+
await new Promise(resolve => setTimeout(resolve, pauseDuration * 1000))
|
163 |
+
await this.createDangerSound()
|
164 |
+
}
|
165 |
+
}
|
166 |
+
} catch (error) {
|
167 |
+
console.error('Error playing move sound:', error)
|
168 |
+
}
|
169 |
+
}
|
170 |
+
|
171 |
+
private async createCaptureSound() {
|
172 |
+
if (!this.audioContext || !this.masterGain) return
|
173 |
+
const duration = 0.2
|
174 |
+
const now = this.audioContext.currentTime
|
175 |
+
const frequencies = [400, 800, 1200]
|
176 |
+
const oscillators: OscillatorNode[] = []
|
177 |
+
const gainNodes: GainNode[] = []
|
178 |
+
|
179 |
+
frequencies.forEach((freq, index) => {
|
180 |
+
const oscillator = this.audioContext!.createOscillator()
|
181 |
+
const gainNode = this.audioContext!.createGain()
|
182 |
+
oscillator.connect(gainNode)
|
183 |
+
gainNode.connect(this.masterGain!)
|
184 |
+
oscillator.frequency.value = freq
|
185 |
+
oscillator.type = 'sine'
|
186 |
+
const harmonic_volume = this.gameVolume * (1 / (index + 1))
|
187 |
+
gainNode.gain.setValueAtTime(harmonic_volume, now)
|
188 |
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
|
189 |
+
oscillator.start(now)
|
190 |
+
oscillator.stop(now + duration)
|
191 |
+
oscillators.push(oscillator)
|
192 |
+
gainNodes.push(gainNode)
|
193 |
+
})
|
194 |
+
|
195 |
+
return new Promise<void>(resolve => {
|
196 |
+
oscillators[oscillators.length - 1].onended = () => resolve()
|
197 |
+
})
|
198 |
+
}
|
199 |
+
|
200 |
+
private async createDangerSound() {
|
201 |
+
if (!this.audioContext || !this.masterGain) return
|
202 |
+
const duration = 0.15
|
203 |
+
const now = this.audioContext.currentTime
|
204 |
+
const oscillator = this.audioContext.createOscillator()
|
205 |
+
const gainNode = this.audioContext.createGain()
|
206 |
+
oscillator.connect(gainNode)
|
207 |
+
gainNode.connect(this.masterGain)
|
208 |
+
oscillator.frequency.value = 1000
|
209 |
+
oscillator.type = 'sawtooth'
|
210 |
+
gainNode.gain.setValueAtTime(this.gameVolume, now)
|
211 |
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
|
212 |
+
oscillator.start(now)
|
213 |
+
oscillator.stop(now + duration)
|
214 |
+
|
215 |
+
return new Promise<void>(resolve => {
|
216 |
+
oscillator.onended = () => resolve()
|
217 |
+
})
|
218 |
+
}
|
219 |
+
|
220 |
+
private checkForDanger(square: Square, board: Chess): boolean {
|
221 |
+
try {
|
222 |
+
const moves = board.moves({ square, verbose: true })
|
223 |
+
return moves.some((move: any) => move.captured)
|
224 |
+
} catch {
|
225 |
+
return false
|
226 |
+
}
|
227 |
+
}
|
228 |
+
|
229 |
+
|
230 |
+
async updatePositionAudio(board: Chess, userColor: Color = 'w') {
|
231 |
+
if (!this.isActive || this.volume === 0) return
|
232 |
+
|
233 |
+
try {
|
234 |
+
await this.ensureAudioContext()
|
235 |
+
this.startContinuousInitiativeAudio(board, userColor)
|
236 |
+
} catch (error) {
|
237 |
+
console.error('Error updating position audio:', error)
|
238 |
+
}
|
239 |
+
}
|
240 |
+
|
241 |
+
private startContinuousInitiativeAudio(board: Chess, userColor: Color = 'w') {
|
242 |
+
if (!this.audioContext || !this.masterGain) return
|
243 |
+
this.stopPositionAudio()
|
244 |
+
const userInitiative = PositionEvaluator.getInitiative(board, userColor)
|
245 |
+
const userBeatsPerMinute = userInitiative * 120
|
246 |
+
if (userBeatsPerMinute > 0) {
|
247 |
+
const userInterval = (60 / userBeatsPerMinute) * 1000
|
248 |
+
this.userInterval = setInterval(() => {
|
249 |
+
this.playInitiativeBeat(this.userInitiativeFreq)
|
250 |
+
}, userInterval)
|
251 |
+
}
|
252 |
+
}
|
253 |
+
|
254 |
+
private async playInitiativeBeat(frequency: number) {
|
255 |
+
if (!this.audioContext || !this.masterGain || this.volume === 0) return
|
256 |
+
const oscillator = this.audioContext.createOscillator()
|
257 |
+
const gainNode = this.audioContext.createGain()
|
258 |
+
oscillator.connect(gainNode)
|
259 |
+
gainNode.connect(this.masterGain)
|
260 |
+
oscillator.frequency.value = frequency
|
261 |
+
oscillator.type = 'sine'
|
262 |
+
const now = this.audioContext.currentTime
|
263 |
+
const duration = 0.1
|
264 |
+
const volume = this.ambientVolume
|
265 |
+
gainNode.gain.setValueAtTime(volume, now)
|
266 |
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration)
|
267 |
+
oscillator.start(now)
|
268 |
+
oscillator.stop(now + duration)
|
269 |
+
}
|
270 |
+
|
271 |
+
updateInitiativeVolumes(board: Chess, userColor: Color = 'w') {
|
272 |
+
this.startContinuousInitiativeAudio(board, userColor)
|
273 |
+
}
|
274 |
+
|
275 |
+
stopPositionAudio() {
|
276 |
+
if (this.userInterval) {
|
277 |
+
clearInterval(this.userInterval)
|
278 |
+
this.userInterval = null
|
279 |
+
}
|
280 |
+
}
|
281 |
+
|
282 |
+
stopAllAudio() {
|
283 |
+
this.stopPositionAudio()
|
284 |
+
if (this.audioContext) {
|
285 |
+
try {
|
286 |
+
if (this.masterGain) {
|
287 |
+
this.masterGain.disconnect()
|
288 |
+
this.masterGain = this.audioContext.createGain()
|
289 |
+
this.masterGain.connect(this.audioContext.destination)
|
290 |
+
this.masterGain.gain.value = this.volume
|
291 |
+
}
|
292 |
+
} catch (error) {
|
293 |
+
console.error('Error stopping audio:', error)
|
294 |
+
}
|
295 |
+
}
|
296 |
+
}
|
297 |
+
|
298 |
+
isPlaying(): boolean {
|
299 |
+
return this.userInterval !== null
|
300 |
+
}
|
301 |
+
|
302 |
+
cleanup() {
|
303 |
+
this.stopAllAudio()
|
304 |
+
this.isActive = false
|
305 |
+
|
306 |
+
if (this.audioContext) {
|
307 |
+
this.audioContext.close()
|
308 |
+
this.audioContext = null
|
309 |
+
}
|
310 |
+
}
|
311 |
+
}
|
src/engines/ChessAI.ts
ADDED
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Chess, Move } from 'chess.js'
|
2 |
+
import { pipeline } from '@xenova/transformers'
|
3 |
+
|
4 |
+
export class ChessAI {
|
5 |
+
private model: any = null
|
6 |
+
private isLoading: boolean = false
|
7 |
+
private modelId: string
|
8 |
+
constructor(modelId: string = 'mlabonne/chesspythia-70m') {
|
9 |
+
this.modelId = modelId
|
10 |
+
}
|
11 |
+
|
12 |
+
async initialize(): Promise<void> {
|
13 |
+
if (this.model || this.isLoading) return
|
14 |
+
|
15 |
+
this.isLoading = true
|
16 |
+
try {
|
17 |
+
this.model = await pipeline('text-generation', this.modelId)
|
18 |
+
} catch (error) {
|
19 |
+
console.error('Failed to load chess model:', error)
|
20 |
+
this.model = null
|
21 |
+
} finally {
|
22 |
+
this.isLoading = false
|
23 |
+
}
|
24 |
+
}
|
25 |
+
|
26 |
+
async getMove(chess: Chess, timeLimit: number = 10000): Promise<Move | null> {
|
27 |
+
if (!this.model) {
|
28 |
+
console.log('Using fallback AI (model not loaded)')
|
29 |
+
return this.getFallbackMove(chess)
|
30 |
+
}
|
31 |
+
|
32 |
+
try {
|
33 |
+
const legalMoves = chess.moves({ verbose: true })
|
34 |
+
if (legalMoves.length === 0) return null
|
35 |
+
const prompt = this.createChessPrompt(chess)
|
36 |
+
const startTime = Date.now()
|
37 |
+
const result = await Promise.race([
|
38 |
+
this.generateMove(prompt, legalMoves),
|
39 |
+
new Promise<null>((_, reject) =>
|
40 |
+
setTimeout(() => reject(new Error('Timeout')), timeLimit)
|
41 |
+
)
|
42 |
+
])
|
43 |
+
const elapsedTime = Date.now() - startTime
|
44 |
+
console.log(`AI move generated in ${elapsedTime}ms`)
|
45 |
+
return result || this.getFallbackMove(chess)
|
46 |
+
} catch (error) {
|
47 |
+
console.error('Error generating AI move:', error)
|
48 |
+
return this.getFallbackMove(chess)
|
49 |
+
}
|
50 |
+
}
|
51 |
+
|
52 |
+
private async generateMove(prompt: string, legalMoves: Move[]): Promise<Move | null> {
|
53 |
+
if (!this.model) return null
|
54 |
+
|
55 |
+
try {
|
56 |
+
const output = await this.model(prompt, {
|
57 |
+
max_new_tokens: 10,
|
58 |
+
temperature: 0.7
|
59 |
+
})
|
60 |
+
const generatedText = Array.isArray(output) ? output[0]?.generated_text : output.generated_text
|
61 |
+
console.log('Model output:', generatedText)
|
62 |
+
const move = this.parseMove(generatedText, legalMoves)
|
63 |
+
return move
|
64 |
+
} catch (error) {
|
65 |
+
console.error('Error in model generation:', error)
|
66 |
+
return null
|
67 |
+
}
|
68 |
+
}
|
69 |
+
|
70 |
+
private createChessPrompt(chess: Chess): string {
|
71 |
+
const turn = chess.turn()
|
72 |
+
const moveNumber = chess.moveNumber()
|
73 |
+
if (turn === 'w') {
|
74 |
+
return `${moveNumber}.`
|
75 |
+
} else {
|
76 |
+
return `${moveNumber}...`
|
77 |
+
}
|
78 |
+
}
|
79 |
+
|
80 |
+
private parseMove(generatedText: string, legalMoves: Move[]): Move | null {
|
81 |
+
if (!generatedText) return null
|
82 |
+
const cleanText = generatedText.trim().replace(/[+#]$/, '')
|
83 |
+
for (const move of legalMoves) {
|
84 |
+
if (move.san === cleanText || move.lan === cleanText) {
|
85 |
+
return move
|
86 |
+
}
|
87 |
+
}
|
88 |
+
for (const move of legalMoves) {
|
89 |
+
if (move.san.startsWith(cleanText) || cleanText.includes(move.san)) {
|
90 |
+
return move
|
91 |
+
}
|
92 |
+
}
|
93 |
+
console.log(`Could not parse move "${cleanText}" from legal moves:`, legalMoves.map(m => m.san))
|
94 |
+
return null
|
95 |
+
}
|
96 |
+
|
97 |
+
private getFallbackMove(chess: Chess): Move | null {
|
98 |
+
const legalMoves = chess.moves({ verbose: true })
|
99 |
+
if (legalMoves.length === 0) return null
|
100 |
+
let candidateMoves = legalMoves.filter(move => move.captured)
|
101 |
+
if (candidateMoves.length === 0) {
|
102 |
+
candidateMoves = legalMoves.filter(move => {
|
103 |
+
chess.move(move)
|
104 |
+
const isCheck = chess.inCheck()
|
105 |
+
chess.undo()
|
106 |
+
return isCheck
|
107 |
+
})
|
108 |
+
}
|
109 |
+
if (candidateMoves.length === 0) {
|
110 |
+
candidateMoves = legalMoves
|
111 |
+
}
|
112 |
+
const randomIndex = Math.floor(Math.random() * candidateMoves.length)
|
113 |
+
return candidateMoves[randomIndex]
|
114 |
+
}
|
115 |
+
|
116 |
+
isModelLoaded(): boolean {
|
117 |
+
return this.model !== null
|
118 |
+
}
|
119 |
+
|
120 |
+
isModelLoading(): boolean {
|
121 |
+
return this.isLoading
|
122 |
+
}
|
123 |
+
|
124 |
+
getModelInfo(): string {
|
125 |
+
if (this.isLoading) return 'Loading...'
|
126 |
+
if (this.model) return `${this.modelId} (Loaded)`
|
127 |
+
return `${this.modelId} (Not loaded)`
|
128 |
+
}
|
129 |
+
}
|
src/engines/PositionEvaluator.ts
ADDED
@@ -0,0 +1,443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Chess, Square, PieceSymbol, Color } from 'chess.js'
|
2 |
+
|
3 |
+
export class PositionEvaluator {
|
4 |
+
private static readonly PIECE_VALUES: Record<PieceSymbol, number> = {
|
5 |
+
'p': 100,
|
6 |
+
'n': 320,
|
7 |
+
'b': 330,
|
8 |
+
'r': 500,
|
9 |
+
'q': 900,
|
10 |
+
'k': 20000
|
11 |
+
}
|
12 |
+
|
13 |
+
private static readonly PAWN_TABLE = [
|
14 |
+
0, 0, 0, 0, 0, 0, 0, 0,
|
15 |
+
50, 50, 50, 50, 50, 50, 50, 50,
|
16 |
+
10, 10, 20, 30, 30, 20, 10, 10,
|
17 |
+
5, 5, 10, 25, 25, 10, 5, 5,
|
18 |
+
0, 0, 0, 20, 20, 0, 0, 0,
|
19 |
+
5, -5,-10, 0, 0,-10, -5, 5,
|
20 |
+
5, 10, 10,-20,-20, 10, 10, 5,
|
21 |
+
0, 0, 0, 0, 0, 0, 0, 0
|
22 |
+
]
|
23 |
+
|
24 |
+
private static readonly KNIGHT_TABLE = [
|
25 |
+
-50,-40,-30,-30,-30,-30,-40,-50,
|
26 |
+
-40,-20, 0, 0, 0, 0,-20,-40,
|
27 |
+
-30, 0, 10, 15, 15, 10, 0,-30,
|
28 |
+
-30, 5, 15, 20, 20, 15, 5,-30,
|
29 |
+
-30, 0, 15, 20, 20, 15, 0,-30,
|
30 |
+
-30, 5, 10, 15, 15, 10, 5,-30,
|
31 |
+
-40,-20, 0, 5, 5, 0,-20,-40,
|
32 |
+
-50,-40,-30,-30,-30,-30,-40,-50
|
33 |
+
]
|
34 |
+
|
35 |
+
private static readonly BISHOP_TABLE = [
|
36 |
+
-20,-10,-10,-10,-10,-10,-10,-20,
|
37 |
+
-10, 0, 0, 0, 0, 0, 0,-10,
|
38 |
+
-10, 0, 5, 10, 10, 5, 0,-10,
|
39 |
+
-10, 5, 5, 10, 10, 5, 5,-10,
|
40 |
+
-10, 0, 10, 10, 10, 10, 0,-10,
|
41 |
+
-10, 10, 10, 10, 10, 10, 10,-10,
|
42 |
+
-10, 5, 0, 0, 0, 0, 5,-10,
|
43 |
+
-20,-10,-10,-10,-10,-10,-10,-20
|
44 |
+
]
|
45 |
+
|
46 |
+
private static readonly ROOK_TABLE = [
|
47 |
+
0, 0, 0, 0, 0, 0, 0, 0,
|
48 |
+
5, 10, 10, 10, 10, 10, 10, 5,
|
49 |
+
-5, 0, 0, 0, 0, 0, 0, -5,
|
50 |
+
-5, 0, 0, 0, 0, 0, 0, -5,
|
51 |
+
-5, 0, 0, 0, 0, 0, 0, -5,
|
52 |
+
-5, 0, 0, 0, 0, 0, 0, -5,
|
53 |
+
-5, 0, 0, 0, 0, 0, 0, -5,
|
54 |
+
0, 0, 0, 5, 5, 0, 0, 0
|
55 |
+
]
|
56 |
+
|
57 |
+
private static readonly QUEEN_TABLE = [
|
58 |
+
-20,-10,-10, -5, -5,-10,-10,-20,
|
59 |
+
-10, 0, 0, 0, 0, 0, 0,-10,
|
60 |
+
-10, 0, 5, 5, 5, 5, 0,-10,
|
61 |
+
-5, 0, 5, 5, 5, 5, 0, -5,
|
62 |
+
0, 0, 5, 5, 5, 5, 0, -5,
|
63 |
+
-10, 5, 5, 5, 5, 5, 0,-10,
|
64 |
+
-10, 0, 5, 0, 0, 0, 0,-10,
|
65 |
+
-20,-10,-10, -5, -5,-10,-10,-20
|
66 |
+
]
|
67 |
+
|
68 |
+
private static readonly KING_MIDDLE_GAME_TABLE = [
|
69 |
+
-30,-40,-40,-50,-50,-40,-40,-30,
|
70 |
+
-30,-40,-40,-50,-50,-40,-40,-30,
|
71 |
+
-30,-40,-40,-50,-50,-40,-40,-30,
|
72 |
+
-30,-40,-40,-50,-50,-40,-40,-30,
|
73 |
+
-20,-30,-30,-40,-40,-30,-30,-20,
|
74 |
+
-10,-20,-20,-20,-20,-20,-20,-10,
|
75 |
+
20, 20, 0, 0, 0, 0, 20, 20,
|
76 |
+
20, 30, 10, 0, 0, 10, 30, 20
|
77 |
+
]
|
78 |
+
|
79 |
+
private static readonly KING_END_GAME_TABLE = [
|
80 |
+
-50,-40,-30,-20,-20,-30,-40,-50,
|
81 |
+
-30,-20,-10, 0, 0,-10,-20,-30,
|
82 |
+
-30,-10, 20, 30, 30, 20,-10,-30,
|
83 |
+
-30,-10, 30, 40, 40, 30,-10,-30,
|
84 |
+
-30,-10, 30, 40, 40, 30,-10,-30,
|
85 |
+
-30,-10, 20, 30, 30, 20,-10,-30,
|
86 |
+
-30,-30, 0, 0, 0, 0,-30,-30,
|
87 |
+
-50,-30,-30,-30,-30,-30,-30,-50
|
88 |
+
]
|
89 |
+
|
90 |
+
/**
|
91 |
+
* Evaluates the current position from White's perspective
|
92 |
+
* Positive values favor White, negative values favor Black
|
93 |
+
*/
|
94 |
+
public static evaluatePosition(board: Chess): number {
|
95 |
+
if (board.isGameOver()) {
|
96 |
+
if (board.isCheckmate()) {
|
97 |
+
return board.turn() === 'w' ? -30000 : 30000
|
98 |
+
}
|
99 |
+
return 0 // draw
|
100 |
+
}
|
101 |
+
|
102 |
+
let score = 0
|
103 |
+
const isEndGame = this.isEndGame(board)
|
104 |
+
|
105 |
+
// material and positional evaluation
|
106 |
+
for (let rank = 0; rank < 8; rank++) {
|
107 |
+
for (let file = 0; file < 8; file++) {
|
108 |
+
const square = (String.fromCharCode(97 + file) + (rank + 1)) as Square
|
109 |
+
const piece = board.get(square)
|
110 |
+
|
111 |
+
if (piece) {
|
112 |
+
const pieceValue = this.evaluatePiece(piece.type, piece.color, rank, file, isEndGame)
|
113 |
+
score += piece.color === 'w' ? pieceValue : -pieceValue
|
114 |
+
}
|
115 |
+
}
|
116 |
+
}
|
117 |
+
|
118 |
+
// mobility bonus
|
119 |
+
const { whiteMobility, blackMobility } = this.calculateMobility(board)
|
120 |
+
score += (whiteMobility - blackMobility) * 10
|
121 |
+
|
122 |
+
// king safety in middle game
|
123 |
+
if (!isEndGame) {
|
124 |
+
score += this.evaluateKingSafety(board, 'w')
|
125 |
+
score -= this.evaluateKingSafety(board, 'b')
|
126 |
+
}
|
127 |
+
|
128 |
+
// pawn structure
|
129 |
+
score += this.evaluatePawnStructure(board)
|
130 |
+
|
131 |
+
// center control
|
132 |
+
score += this.evaluateCenterControl(board)
|
133 |
+
|
134 |
+
return score
|
135 |
+
}
|
136 |
+
|
137 |
+
private static evaluatePiece(
|
138 |
+
pieceType: PieceSymbol,
|
139 |
+
color: Color,
|
140 |
+
rank: number,
|
141 |
+
file: number,
|
142 |
+
isEndGame: boolean
|
143 |
+
): number {
|
144 |
+
const index = rank * 8 + file
|
145 |
+
const flippedIndex = color === 'w' ? (7 - rank) * 8 + file : index
|
146 |
+
|
147 |
+
let value = this.PIECE_VALUES[pieceType]
|
148 |
+
|
149 |
+
switch (pieceType) {
|
150 |
+
case 'p':
|
151 |
+
value += this.PAWN_TABLE[flippedIndex]
|
152 |
+
break
|
153 |
+
case 'n':
|
154 |
+
value += this.KNIGHT_TABLE[flippedIndex]
|
155 |
+
break
|
156 |
+
case 'b':
|
157 |
+
value += this.BISHOP_TABLE[flippedIndex]
|
158 |
+
break
|
159 |
+
case 'r':
|
160 |
+
value += this.ROOK_TABLE[flippedIndex]
|
161 |
+
break
|
162 |
+
case 'q':
|
163 |
+
value += this.QUEEN_TABLE[flippedIndex]
|
164 |
+
break
|
165 |
+
case 'k':
|
166 |
+
if (isEndGame) {
|
167 |
+
value += this.KING_END_GAME_TABLE[flippedIndex]
|
168 |
+
} else {
|
169 |
+
value += this.KING_MIDDLE_GAME_TABLE[flippedIndex]
|
170 |
+
}
|
171 |
+
break
|
172 |
+
}
|
173 |
+
|
174 |
+
return value
|
175 |
+
}
|
176 |
+
|
177 |
+
private static isEndGame(board: Chess): boolean {
|
178 |
+
let materialCount = 0
|
179 |
+
const squares = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
|
180 |
+
|
181 |
+
for (const file of squares) {
|
182 |
+
for (let rank = 1; rank <= 8; rank++) {
|
183 |
+
const square = (file + rank) as Square
|
184 |
+
const piece = board.get(square)
|
185 |
+
if (piece && piece.type !== 'k' && piece.type !== 'p') {
|
186 |
+
materialCount += this.PIECE_VALUES[piece.type]
|
187 |
+
}
|
188 |
+
}
|
189 |
+
}
|
190 |
+
|
191 |
+
return materialCount < 2500
|
192 |
+
}
|
193 |
+
|
194 |
+
private static evaluateKingSafety(board: Chess, color: Color): number {
|
195 |
+
const kingSquare = this.findKing(board, color)
|
196 |
+
if (!kingSquare) return 0
|
197 |
+
|
198 |
+
let safety = 0
|
199 |
+
const kingFile = kingSquare.charCodeAt(0) - 97
|
200 |
+
const kingRank = parseInt(kingSquare[1]) - 1
|
201 |
+
|
202 |
+
// check for pawn shield
|
203 |
+
const pawnShieldRank = color === 'w' ? kingRank + 1 : kingRank - 1
|
204 |
+
if (pawnShieldRank >= 0 && pawnShieldRank < 8) {
|
205 |
+
for (let file = Math.max(0, kingFile - 1); file <= Math.min(7, kingFile + 1); file++) {
|
206 |
+
const shieldSquare = (String.fromCharCode(97 + file) + (pawnShieldRank + 1)) as Square
|
207 |
+
const piece = board.get(shieldSquare)
|
208 |
+
if (piece && piece.type === 'p' && piece.color === color) {
|
209 |
+
safety += 30
|
210 |
+
}
|
211 |
+
}
|
212 |
+
}
|
213 |
+
|
214 |
+
return safety
|
215 |
+
}
|
216 |
+
|
217 |
+
private static evaluatePawnStructure(board: Chess): number {
|
218 |
+
let score = 0
|
219 |
+
const files = [0, 0, 0, 0, 0, 0, 0, 0]
|
220 |
+
|
221 |
+
// count pawns
|
222 |
+
for (let file = 0; file < 8; file++) {
|
223 |
+
let whitePawns = 0
|
224 |
+
let blackPawns = 0
|
225 |
+
|
226 |
+
for (let rank = 0; rank < 8; rank++) {
|
227 |
+
const square = (String.fromCharCode(97 + file) + (rank + 1)) as Square
|
228 |
+
const piece = board.get(square)
|
229 |
+
if (piece && piece.type === 'p') {
|
230 |
+
if (piece.color === 'w') whitePawns++
|
231 |
+
else blackPawns++
|
232 |
+
}
|
233 |
+
}
|
234 |
+
|
235 |
+
// penalty for doubled pawns
|
236 |
+
if (whitePawns > 1) score -= (whitePawns - 1) * 50
|
237 |
+
if (blackPawns > 1) score += (blackPawns - 1) * 50
|
238 |
+
|
239 |
+
files[file] = whitePawns - blackPawns
|
240 |
+
}
|
241 |
+
|
242 |
+
// penalty for isolated pawns
|
243 |
+
for (let file = 0; file < 8; file++) {
|
244 |
+
if (files[file] !== 0) {
|
245 |
+
const leftFile = file > 0 ? files[file - 1] : 0
|
246 |
+
const rightFile = file < 7 ? files[file + 1] : 0
|
247 |
+
|
248 |
+
if (leftFile === 0 && rightFile === 0) {
|
249 |
+
score -= Math.abs(files[file]) * 20
|
250 |
+
}
|
251 |
+
}
|
252 |
+
}
|
253 |
+
|
254 |
+
return score
|
255 |
+
}
|
256 |
+
|
257 |
+
private static evaluateCenterControl(board: Chess): number {
|
258 |
+
let score = 0
|
259 |
+
const centerSquares = ['d4', 'd5', 'e4', 'e5']
|
260 |
+
|
261 |
+
for (const square of centerSquares) {
|
262 |
+
const piece = board.get(square as Square)
|
263 |
+
if (piece) {
|
264 |
+
const value = piece.type === 'p' ? 20 : 10
|
265 |
+
score += piece.color === 'w' ? value : -value
|
266 |
+
}
|
267 |
+
}
|
268 |
+
|
269 |
+
return score
|
270 |
+
}
|
271 |
+
|
272 |
+
private static findKing(board: Chess, color: Color): Square | null {
|
273 |
+
const squares = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
|
274 |
+
|
275 |
+
for (const file of squares) {
|
276 |
+
for (let rank = 1; rank <= 8; rank++) {
|
277 |
+
const square = (file + rank) as Square
|
278 |
+
const piece = board.get(square)
|
279 |
+
if (piece && piece.type === 'k' && piece.color === color) {
|
280 |
+
return square
|
281 |
+
}
|
282 |
+
}
|
283 |
+
}
|
284 |
+
|
285 |
+
return null
|
286 |
+
}
|
287 |
+
|
288 |
+
private static calculateMobility(board: Chess): { whiteMobility: number, blackMobility: number } {
|
289 |
+
const originalFen = board.fen()
|
290 |
+
const currentTurn = board.turn()
|
291 |
+
|
292 |
+
let whiteMobility = 0
|
293 |
+
let blackMobility = 0
|
294 |
+
|
295 |
+
if (currentTurn === 'w') {
|
296 |
+
whiteMobility = board.moves().length
|
297 |
+
|
298 |
+
const fenParts = originalFen.split(' ')
|
299 |
+
fenParts[1] = 'b'
|
300 |
+
try {
|
301 |
+
const blackTurnFen = fenParts.join(' ')
|
302 |
+
const blackBoard = new Chess(blackTurnFen)
|
303 |
+
blackMobility = blackBoard.moves().length
|
304 |
+
} catch {
|
305 |
+
blackMobility = 0
|
306 |
+
}
|
307 |
+
} else {
|
308 |
+
blackMobility = board.moves().length
|
309 |
+
const fenParts = originalFen.split(' ')
|
310 |
+
fenParts[1] = 'w'
|
311 |
+
try {
|
312 |
+
const whiteTurnFen = fenParts.join(' ')
|
313 |
+
const whiteBoard = new Chess(whiteTurnFen)
|
314 |
+
whiteMobility = whiteBoard.moves().length
|
315 |
+
} catch {
|
316 |
+
whiteMobility = 0
|
317 |
+
}
|
318 |
+
}
|
319 |
+
|
320 |
+
return { whiteMobility, blackMobility }
|
321 |
+
}
|
322 |
+
|
323 |
+
|
324 |
+
public static evaluateForColor(board: Chess, color: Color): number {
|
325 |
+
const evaluation = this.evaluatePosition(board)
|
326 |
+
return color === 'w' ? evaluation : -evaluation
|
327 |
+
}
|
328 |
+
|
329 |
+
|
330 |
+
public static getInitiative(board: Chess, color: Color): number {
|
331 |
+
const evaluation = this.evaluateForColor(board, color)
|
332 |
+
const gameActivity = this.calculateGameActivity(board)
|
333 |
+
const baseInitiative = Math.max(0, Math.min(1, (evaluation + 500) / 1000))
|
334 |
+
return baseInitiative * gameActivity
|
335 |
+
}
|
336 |
+
|
337 |
+
|
338 |
+
private static calculateGameActivity(board: Chess): number {
|
339 |
+
let activity = 0
|
340 |
+
|
341 |
+
// count pieces not on starting squares
|
342 |
+
const developmentScore = this.calculateDevelopment(board)
|
343 |
+
activity += Math.min(0.4, developmentScore / 8)
|
344 |
+
|
345 |
+
// count tactical opportunities
|
346 |
+
const tacticalScore = this.calculateTacticalActivity(board)
|
347 |
+
activity += Math.min(0.3, tacticalScore / 10)
|
348 |
+
|
349 |
+
// calc material imbalance
|
350 |
+
const materialImbalance = Math.abs(this.calculateMaterialBalance(board))
|
351 |
+
activity += Math.min(0.2, materialImbalance / 500)
|
352 |
+
|
353 |
+
// count king threats
|
354 |
+
const kingSafety = this.calculateKingThreats(board)
|
355 |
+
activity += Math.min(0.1, kingSafety / 5)
|
356 |
+
|
357 |
+
return Math.min(1, activity)
|
358 |
+
}
|
359 |
+
|
360 |
+
private static calculateDevelopment(board: Chess): number {
|
361 |
+
let development = 0
|
362 |
+
|
363 |
+
const startingPositions = {
|
364 |
+
'b1': 'n', 'g1': 'n', 'c1': 'b', 'f1': 'b', 'd1': 'q', // white
|
365 |
+
'b8': 'n', 'g8': 'n', 'c8': 'b', 'f8': 'b', 'd8': 'q' // black
|
366 |
+
}
|
367 |
+
|
368 |
+
for (const [square, expectedPiece] of Object.entries(startingPositions)) {
|
369 |
+
const piece = board.get(square as Square)
|
370 |
+
if (!piece || piece.type !== expectedPiece) {
|
371 |
+
development++
|
372 |
+
}
|
373 |
+
}
|
374 |
+
|
375 |
+
return development
|
376 |
+
}
|
377 |
+
|
378 |
+
private static calculateTacticalActivity(board: Chess): number {
|
379 |
+
let tactical = 0
|
380 |
+
const moves = board.moves({ verbose: true })
|
381 |
+
|
382 |
+
for (const move of moves) {
|
383 |
+
if (move.captured) tactical += 2 // captures
|
384 |
+
if (move.san.includes('+')) tactical += 1 // checks
|
385 |
+
if (move.san.includes('#')) tactical += 3 // checkmate
|
386 |
+
if (move.promotion) tactical += 2 // promotions
|
387 |
+
}
|
388 |
+
|
389 |
+
return tactical
|
390 |
+
}
|
391 |
+
|
392 |
+
private static calculateMaterialBalance(board: Chess): number {
|
393 |
+
let balance = 0
|
394 |
+
const squares = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
|
395 |
+
|
396 |
+
for (const file of squares) {
|
397 |
+
for (let rank = 1; rank <= 8; rank++) {
|
398 |
+
const square = (file + rank) as Square
|
399 |
+
const piece = board.get(square)
|
400 |
+
if (piece && piece.type !== 'k') {
|
401 |
+
const value = this.PIECE_VALUES[piece.type]
|
402 |
+
balance += piece.color === 'w' ? value : -value
|
403 |
+
}
|
404 |
+
}
|
405 |
+
}
|
406 |
+
|
407 |
+
return balance
|
408 |
+
}
|
409 |
+
|
410 |
+
private static calculateKingThreats(board: Chess): number {
|
411 |
+
let threats = 0
|
412 |
+
const whiteKing = this.findKing(board, 'w')
|
413 |
+
const blackKing = this.findKing(board, 'b')
|
414 |
+
if (whiteKing) threats += this.countAttacksNearSquare(board, whiteKing, 'b')
|
415 |
+
if (blackKing) threats += this.countAttacksNearSquare(board, blackKing, 'w')
|
416 |
+
return threats
|
417 |
+
}
|
418 |
+
|
419 |
+
private static countAttacksNearSquare(board: Chess, square: Square, attackingColor: Color): number {
|
420 |
+
let attacks = 0
|
421 |
+
const file = square.charCodeAt(0) - 97
|
422 |
+
const rank = parseInt(square[1]) - 1
|
423 |
+
for (let f = Math.max(0, file - 1); f <= Math.min(7, file + 1); f++) {
|
424 |
+
for (let r = Math.max(0, rank - 1); r <= Math.min(7, rank + 1); r++) {
|
425 |
+
const checkSquare = (String.fromCharCode(97 + f) + (r + 1)) as Square
|
426 |
+
try {
|
427 |
+
const moves = board.moves({ square: checkSquare, verbose: true })
|
428 |
+
const hasAttack = moves.some((move: any) => {
|
429 |
+
const piece = board.get(move.from)
|
430 |
+
return piece && piece.color === attackingColor &&
|
431 |
+
Math.abs(move.to.charCodeAt(0) - square.charCodeAt(0)) <= 1 &&
|
432 |
+
Math.abs(parseInt(move.to[1]) - parseInt(square[1])) <= 1
|
433 |
+
})
|
434 |
+
if (hasAttack) attacks++
|
435 |
+
} catch {
|
436 |
+
// ignore invalid moves
|
437 |
+
}
|
438 |
+
}
|
439 |
+
}
|
440 |
+
|
441 |
+
return attacks
|
442 |
+
}
|
443 |
+
}
|
src/hooks/useChessGame.ts
ADDED
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
2 |
+
import { Chess, Square, Move, Color } from 'chess.js'
|
3 |
+
import { GameState, GameHistoryEntry, DraggedPiece } from '../types/chess'
|
4 |
+
import { evaluateGameState, isPotentialPromotion } from '../utils/chessUtils'
|
5 |
+
import { ChessAI } from '../engines/ChessAI'
|
6 |
+
|
7 |
+
export function useChessGame() {
|
8 |
+
const [gameState, setGameState] = useState<GameState>({
|
9 |
+
board: new Chess(),
|
10 |
+
gameActive: false,
|
11 |
+
playerColor: 'w',
|
12 |
+
selectedSquare: null,
|
13 |
+
legalMoves: [],
|
14 |
+
gameHistory: [],
|
15 |
+
gameOver: false,
|
16 |
+
gameResult: null,
|
17 |
+
promotionMove: null,
|
18 |
+
promotionDialogActive: false,
|
19 |
+
aiThinking: false,
|
20 |
+
aiModelLoaded: false,
|
21 |
+
aiModelLoading: false
|
22 |
+
})
|
23 |
+
const [selectedModel, setSelectedModel] = useState<string>('mlabonne/chesspythia-70m')
|
24 |
+
|
25 |
+
const [draggedPiece, setDraggedPiece] = useState<DraggedPiece | null>(null)
|
26 |
+
const aiTimeoutRef = useRef<NodeJS.Timeout>()
|
27 |
+
const chessAI = useRef<ChessAI | null>(null)
|
28 |
+
|
29 |
+
// init chess model
|
30 |
+
useEffect(() => {
|
31 |
+
const initializeAI = async () => {
|
32 |
+
setGameState(prev => ({ ...prev, aiModelLoading: true }))
|
33 |
+
|
34 |
+
chessAI.current = new ChessAI(selectedModel)
|
35 |
+
|
36 |
+
try {
|
37 |
+
await chessAI.current.initialize()
|
38 |
+
setGameState(prev => ({
|
39 |
+
...prev,
|
40 |
+
aiModelLoaded: true,
|
41 |
+
aiModelLoading: false
|
42 |
+
}))
|
43 |
+
console.log(`Chess AI model ${selectedModel} loaded successfully`)
|
44 |
+
} catch (error) {
|
45 |
+
console.error(`Failed to load Chess AI model ${selectedModel}:`, error)
|
46 |
+
setGameState(prev => ({
|
47 |
+
...prev,
|
48 |
+
aiModelLoaded: false,
|
49 |
+
aiModelLoading: false
|
50 |
+
}))
|
51 |
+
}
|
52 |
+
}
|
53 |
+
|
54 |
+
initializeAI()
|
55 |
+
|
56 |
+
return () => {
|
57 |
+
if (aiTimeoutRef.current) {
|
58 |
+
clearTimeout(aiTimeoutRef.current)
|
59 |
+
}
|
60 |
+
}
|
61 |
+
}, [selectedModel])
|
62 |
+
|
63 |
+
const startNewGame = useCallback(() => {
|
64 |
+
clearTimeout(aiTimeoutRef.current)
|
65 |
+
|
66 |
+
setGameState(prev => ({
|
67 |
+
...prev,
|
68 |
+
board: new Chess(),
|
69 |
+
gameActive: true,
|
70 |
+
gameOver: false,
|
71 |
+
gameHistory: [],
|
72 |
+
selectedSquare: null,
|
73 |
+
legalMoves: [],
|
74 |
+
gameResult: null,
|
75 |
+
promotionDialogActive: false,
|
76 |
+
promotionMove: null,
|
77 |
+
aiThinking: false
|
78 |
+
}))
|
79 |
+
|
80 |
+
if (gameState.playerColor === 'b') {
|
81 |
+
setGameState(prev => ({ ...prev, aiThinking: true }))
|
82 |
+
aiTimeoutRef.current = setTimeout(() => {
|
83 |
+
makeAIMove()
|
84 |
+
}, 500)
|
85 |
+
}
|
86 |
+
}, [gameState.playerColor])
|
87 |
+
|
88 |
+
const resignGame = useCallback(() => {
|
89 |
+
clearTimeout(aiTimeoutRef.current)
|
90 |
+
|
91 |
+
const winner = gameState.playerColor === 'w' ? 'b' : 'w'
|
92 |
+
const gameResult = {
|
93 |
+
isGameOver: true,
|
94 |
+
winner: winner as Color,
|
95 |
+
message: `Game Over! ${gameState.playerColor === 'w' ? 'White' : 'Black'} resigned - ${winner === 'w' ? 'White' : 'Black'} wins!`,
|
96 |
+
terminationReason: 'resignation',
|
97 |
+
details: `${gameState.playerColor === 'w' ? 'White' : 'Black'} player resigned the game`
|
98 |
+
}
|
99 |
+
|
100 |
+
setGameState(prev => ({
|
101 |
+
...prev,
|
102 |
+
gameActive: false,
|
103 |
+
gameOver: true,
|
104 |
+
gameResult,
|
105 |
+
aiThinking: false
|
106 |
+
}))
|
107 |
+
}, [gameState.playerColor])
|
108 |
+
|
109 |
+
const togglePlayerColor = useCallback(() => {
|
110 |
+
if (!gameState.gameActive) {
|
111 |
+
setGameState(prev => ({
|
112 |
+
...prev,
|
113 |
+
playerColor: prev.playerColor === 'w' ? 'b' : 'w'
|
114 |
+
}))
|
115 |
+
}
|
116 |
+
}, [gameState.gameActive])
|
117 |
+
|
118 |
+
const selectSquare = useCallback((square: Square) => {
|
119 |
+
if (!gameState.gameActive || gameState.board.turn() !== gameState.playerColor || gameState.aiThinking) {
|
120 |
+
return
|
121 |
+
}
|
122 |
+
|
123 |
+
const piece = gameState.board.get(square)
|
124 |
+
|
125 |
+
if (piece && piece.color === gameState.playerColor) {
|
126 |
+
const moves = gameState.board.moves({ square, verbose: true })
|
127 |
+
setGameState(prev => ({
|
128 |
+
...prev,
|
129 |
+
selectedSquare: square,
|
130 |
+
legalMoves: moves
|
131 |
+
}))
|
132 |
+
}
|
133 |
+
}, [gameState])
|
134 |
+
|
135 |
+
const attemptMove = useCallback((from: Square, to: Square) => {
|
136 |
+
console.log('attemptMove called:', from, 'to', to)
|
137 |
+
|
138 |
+
if (!gameState.gameActive || gameState.board.turn() !== gameState.playerColor) {
|
139 |
+
console.log('Move rejected: game not active or not player turn')
|
140 |
+
return false
|
141 |
+
}
|
142 |
+
|
143 |
+
if (isPotentialPromotion(gameState.board, from, to)) {
|
144 |
+
const possibleMoves = gameState.board.moves({ square: from, verbose: true })
|
145 |
+
const hasLegalPromotion = possibleMoves.some(move =>
|
146 |
+
move.to === to && move.promotion !== undefined
|
147 |
+
)
|
148 |
+
|
149 |
+
if (hasLegalPromotion) {
|
150 |
+
setGameState(prev => ({
|
151 |
+
...prev,
|
152 |
+
promotionMove: { from, to, promotion: 'q' } as Move,
|
153 |
+
promotionDialogActive: true,
|
154 |
+
selectedSquare: null,
|
155 |
+
legalMoves: []
|
156 |
+
}))
|
157 |
+
return true
|
158 |
+
}
|
159 |
+
}
|
160 |
+
|
161 |
+
try {
|
162 |
+
const testBoard = new Chess(gameState.board.fen())
|
163 |
+
const move = testBoard.move({ from, to })
|
164 |
+
|
165 |
+
if (move) {
|
166 |
+
const historyEntry: GameHistoryEntry = {
|
167 |
+
move: move.san,
|
168 |
+
moveData: move,
|
169 |
+
player: 'Human',
|
170 |
+
timestamp: new Date(),
|
171 |
+
capturedPiece: move.captured ? { type: move.captured, color: gameState.board.turn() === 'w' ? 'b' : 'w' } : undefined
|
172 |
+
}
|
173 |
+
|
174 |
+
setGameState(prev => ({
|
175 |
+
...prev,
|
176 |
+
board: testBoard,
|
177 |
+
gameHistory: [...prev.gameHistory, historyEntry],
|
178 |
+
selectedSquare: null,
|
179 |
+
legalMoves: []
|
180 |
+
}))
|
181 |
+
|
182 |
+
const gameResult = evaluateGameState(testBoard)
|
183 |
+
if (gameResult.isGameOver) {
|
184 |
+
setGameState(prev => ({
|
185 |
+
...prev,
|
186 |
+
gameActive: false,
|
187 |
+
gameOver: true,
|
188 |
+
gameResult
|
189 |
+
}))
|
190 |
+
} else {
|
191 |
+
// trigger AI move
|
192 |
+
setGameState(prev => ({ ...prev, aiThinking: true }))
|
193 |
+
aiTimeoutRef.current = setTimeout(() => {
|
194 |
+
makeAIMove()
|
195 |
+
}, 1000)
|
196 |
+
}
|
197 |
+
|
198 |
+
return true
|
199 |
+
} else {
|
200 |
+
console.log('Move returned null/false')
|
201 |
+
}
|
202 |
+
} catch (error) {
|
203 |
+
console.log('Invalid move attempted:', error)
|
204 |
+
}
|
205 |
+
|
206 |
+
return false
|
207 |
+
}, [gameState])
|
208 |
+
|
209 |
+
|
210 |
+
const makeAIMove = useCallback(async () => {
|
211 |
+
if (!chessAI.current) {
|
212 |
+
setGameState(prev => ({ ...prev, aiThinking: false }))
|
213 |
+
return
|
214 |
+
}
|
215 |
+
|
216 |
+
try {
|
217 |
+
setGameState(prev => {
|
218 |
+
const currentBoard = new Chess(prev.board.fen())
|
219 |
+
|
220 |
+
if (!prev.gameActive || currentBoard.turn() === prev.playerColor) {
|
221 |
+
return { ...prev, aiThinking: false }
|
222 |
+
}
|
223 |
+
|
224 |
+
const possibleMoves = currentBoard.moves({ verbose: true })
|
225 |
+
if (possibleMoves.length === 0) {
|
226 |
+
return { ...prev, aiThinking: false }
|
227 |
+
}
|
228 |
+
|
229 |
+
chessAI.current!.getMove(currentBoard, 10000).then(aiMove => {
|
230 |
+
|
231 |
+
if (!aiMove) {
|
232 |
+
setGameState(prev => ({ ...prev, aiThinking: false }))
|
233 |
+
return
|
234 |
+
}
|
235 |
+
|
236 |
+
const moveResult = currentBoard.move(aiMove)
|
237 |
+
if (!moveResult) {
|
238 |
+
setGameState(prev => ({ ...prev, aiThinking: false }))
|
239 |
+
return
|
240 |
+
}
|
241 |
+
|
242 |
+
const historyEntry: GameHistoryEntry = {
|
243 |
+
move: moveResult.san,
|
244 |
+
moveData: moveResult,
|
245 |
+
player: 'AI',
|
246 |
+
timestamp: new Date(),
|
247 |
+
capturedPiece: moveResult.captured ? { type: moveResult.captured, color: currentBoard.turn() === 'w' ? 'b' : 'w' } : undefined
|
248 |
+
}
|
249 |
+
|
250 |
+
const gameResult = evaluateGameState(currentBoard)
|
251 |
+
|
252 |
+
setGameState(prev => ({
|
253 |
+
...prev,
|
254 |
+
board: currentBoard,
|
255 |
+
gameHistory: [...prev.gameHistory, historyEntry],
|
256 |
+
aiThinking: false,
|
257 |
+
gameActive: !gameResult.isGameOver,
|
258 |
+
gameOver: gameResult.isGameOver,
|
259 |
+
gameResult: gameResult.isGameOver ? gameResult : null
|
260 |
+
}))
|
261 |
+
}).catch(error => {
|
262 |
+
console.error('Error in AI move generation:', error)
|
263 |
+
setGameState(prev => ({ ...prev, aiThinking: false }))
|
264 |
+
})
|
265 |
+
|
266 |
+
return prev
|
267 |
+
})
|
268 |
+
|
269 |
+
} catch (error) {
|
270 |
+
console.error('Error in AI move setup:', error)
|
271 |
+
setGameState(prev => ({ ...prev, aiThinking: false }))
|
272 |
+
}
|
273 |
+
}, [])
|
274 |
+
|
275 |
+
const completePromotion = useCallback((promotionPiece: 'q' | 'r' | 'b' | 'n') => {
|
276 |
+
if (!gameState.promotionMove) return
|
277 |
+
|
278 |
+
try {
|
279 |
+
const testBoard = new Chess(gameState.board.fen())
|
280 |
+
const move = testBoard.move({
|
281 |
+
from: gameState.promotionMove.from,
|
282 |
+
to: gameState.promotionMove.to,
|
283 |
+
promotion: promotionPiece
|
284 |
+
})
|
285 |
+
|
286 |
+
if (move) {
|
287 |
+
const historyEntry: GameHistoryEntry = {
|
288 |
+
move: move.san,
|
289 |
+
moveData: move,
|
290 |
+
player: 'Human',
|
291 |
+
timestamp: new Date(),
|
292 |
+
capturedPiece: move.captured ? { type: move.captured, color: gameState.board.turn() === 'w' ? 'b' : 'w' } : undefined
|
293 |
+
}
|
294 |
+
|
295 |
+
setGameState(prev => ({
|
296 |
+
...prev,
|
297 |
+
board: testBoard,
|
298 |
+
gameHistory: [...prev.gameHistory, historyEntry],
|
299 |
+
selectedSquare: null,
|
300 |
+
legalMoves: [],
|
301 |
+
promotionDialogActive: false,
|
302 |
+
promotionMove: null
|
303 |
+
}))
|
304 |
+
|
305 |
+
const gameResult = evaluateGameState(testBoard)
|
306 |
+
if (gameResult.isGameOver) {
|
307 |
+
setGameState(prev => ({
|
308 |
+
...prev,
|
309 |
+
gameActive: false,
|
310 |
+
gameOver: true,
|
311 |
+
gameResult
|
312 |
+
}))
|
313 |
+
} else {
|
314 |
+
setGameState(prev => ({ ...prev, aiThinking: true }))
|
315 |
+
aiTimeoutRef.current = setTimeout(() => {
|
316 |
+
makeAIMove()
|
317 |
+
}, 1000)
|
318 |
+
}
|
319 |
+
} else {
|
320 |
+
setGameState(prev => ({
|
321 |
+
...prev,
|
322 |
+
promotionDialogActive: false,
|
323 |
+
promotionMove: null
|
324 |
+
}))
|
325 |
+
}
|
326 |
+
} catch (error) {
|
327 |
+
console.error('Error during promotion:', error)
|
328 |
+
setGameState(prev => ({
|
329 |
+
...prev,
|
330 |
+
promotionDialogActive: false,
|
331 |
+
promotionMove: null
|
332 |
+
}))
|
333 |
+
}
|
334 |
+
}, [gameState.promotionMove, gameState.board, makeAIMove])
|
335 |
+
|
336 |
+
const startDrag = useCallback((square: Square) => {
|
337 |
+
if (!gameState.gameActive || gameState.board.turn() !== gameState.playerColor || gameState.aiThinking) {
|
338 |
+
return
|
339 |
+
}
|
340 |
+
|
341 |
+
const piece = gameState.board.get(square)
|
342 |
+
if (piece && piece.color === gameState.playerColor) {
|
343 |
+
setDraggedPiece({ piece, square })
|
344 |
+
const moves = gameState.board.moves({ square, verbose: true })
|
345 |
+
setGameState(prev => ({
|
346 |
+
...prev,
|
347 |
+
selectedSquare: square,
|
348 |
+
legalMoves: moves
|
349 |
+
}))
|
350 |
+
}
|
351 |
+
}, [gameState])
|
352 |
+
|
353 |
+
const endDrag = useCallback((targetSquare: Square | null) => {
|
354 |
+
console.log('Ending drag at:', targetSquare, 'from:', draggedPiece?.square)
|
355 |
+
|
356 |
+
let moveSuccessful = false
|
357 |
+
if (draggedPiece && targetSquare && targetSquare !== draggedPiece.square) {
|
358 |
+
moveSuccessful = attemptMove(draggedPiece.square, targetSquare)
|
359 |
+
}
|
360 |
+
setDraggedPiece(null)
|
361 |
+
|
362 |
+
if (!moveSuccessful) {
|
363 |
+
setGameState(prev => ({
|
364 |
+
...prev,
|
365 |
+
selectedSquare: null,
|
366 |
+
legalMoves: []
|
367 |
+
}))
|
368 |
+
}
|
369 |
+
}, [draggedPiece, attemptMove])
|
370 |
+
|
371 |
+
const getAIStatus = useCallback(() => {
|
372 |
+
if (!chessAI.current) return 'Not initialized'
|
373 |
+
return chessAI.current.getModelInfo()
|
374 |
+
}, [])
|
375 |
+
|
376 |
+
const changeModel = useCallback((modelId: string) => {
|
377 |
+
if (gameState.gameActive) return
|
378 |
+
setSelectedModel(modelId)
|
379 |
+
}, [gameState.gameActive])
|
380 |
+
|
381 |
+
useEffect(() => {
|
382 |
+
return () => {
|
383 |
+
if (aiTimeoutRef.current) {
|
384 |
+
clearTimeout(aiTimeoutRef.current)
|
385 |
+
}
|
386 |
+
}
|
387 |
+
}, [])
|
388 |
+
|
389 |
+
return {
|
390 |
+
gameState,
|
391 |
+
draggedPiece,
|
392 |
+
selectedModel,
|
393 |
+
startNewGame,
|
394 |
+
resignGame,
|
395 |
+
togglePlayerColor,
|
396 |
+
selectSquare,
|
397 |
+
attemptMove,
|
398 |
+
completePromotion,
|
399 |
+
startDrag,
|
400 |
+
endDrag,
|
401 |
+
getAIStatus,
|
402 |
+
changeModel
|
403 |
+
}
|
404 |
+
}
|
src/main.tsx
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react'
|
2 |
+
import ReactDOM from 'react-dom/client'
|
3 |
+
import App from './App'
|
4 |
+
import { ErrorBoundary } from './ErrorBoundary'
|
5 |
+
import './styles/index.css'
|
6 |
+
|
7 |
+
console.log('Starting Musical Chess React App...')
|
8 |
+
|
9 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
10 |
+
<React.StrictMode>
|
11 |
+
<ErrorBoundary>
|
12 |
+
<App />
|
13 |
+
</ErrorBoundary>
|
14 |
+
</React.StrictMode>,
|
15 |
+
)
|
src/styles/App.css
ADDED
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.app-container {
|
2 |
+
width: 100vw;
|
3 |
+
height: 100vh;
|
4 |
+
background-color: #323232;
|
5 |
+
color: white;
|
6 |
+
display: flex;
|
7 |
+
justify-content: center;
|
8 |
+
align-items: flex-start;
|
9 |
+
overflow: auto;
|
10 |
+
padding-top: 5vh;
|
11 |
+
}
|
12 |
+
|
13 |
+
.main-content {
|
14 |
+
display: flex;
|
15 |
+
flex-direction: column;
|
16 |
+
padding: 20px;
|
17 |
+
gap: 20px;
|
18 |
+
max-width: 100%;
|
19 |
+
max-height: 100%;
|
20 |
+
}
|
21 |
+
|
22 |
+
.header {
|
23 |
+
display: flex;
|
24 |
+
align-items: center;
|
25 |
+
justify-content: center;
|
26 |
+
gap: 20px;
|
27 |
+
position: relative;
|
28 |
+
}
|
29 |
+
|
30 |
+
.title {
|
31 |
+
font-size: 48px;
|
32 |
+
font-weight: bold;
|
33 |
+
margin: 0;
|
34 |
+
text-align: center;
|
35 |
+
color: #ffffff;
|
36 |
+
}
|
37 |
+
|
38 |
+
.audio-info-button {
|
39 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
40 |
+
border: none;
|
41 |
+
border-radius: 50%;
|
42 |
+
width: 50px;
|
43 |
+
height: 50px;
|
44 |
+
font-size: 24px;
|
45 |
+
cursor: pointer;
|
46 |
+
color: white;
|
47 |
+
transition: all 0.2s ease;
|
48 |
+
display: flex;
|
49 |
+
align-items: center;
|
50 |
+
justify-content: center;
|
51 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
52 |
+
}
|
53 |
+
|
54 |
+
.audio-info-button:hover {
|
55 |
+
transform: scale(1.1);
|
56 |
+
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
57 |
+
}
|
58 |
+
|
59 |
+
.game-area {
|
60 |
+
display: flex;
|
61 |
+
gap: 50px;
|
62 |
+
align-items: flex-start;
|
63 |
+
justify-content: center;
|
64 |
+
}
|
65 |
+
|
66 |
+
.board-container {
|
67 |
+
position: relative;
|
68 |
+
flex-shrink: 0;
|
69 |
+
}
|
70 |
+
|
71 |
+
.sidebar {
|
72 |
+
width: 400px;
|
73 |
+
display: flex;
|
74 |
+
flex-direction: column;
|
75 |
+
gap: 20px;
|
76 |
+
padding: 20px;
|
77 |
+
background-color: #464646;
|
78 |
+
border-radius: 10px;
|
79 |
+
height: fit-content;
|
80 |
+
}
|
81 |
+
|
82 |
+
@media (max-width: 1200px) {
|
83 |
+
.game-area {
|
84 |
+
flex-direction: column;
|
85 |
+
align-items: center;
|
86 |
+
gap: 30px;
|
87 |
+
}
|
88 |
+
|
89 |
+
.sidebar {
|
90 |
+
width: 100%;
|
91 |
+
max-width: 600px;
|
92 |
+
}
|
93 |
+
|
94 |
+
.title {
|
95 |
+
font-size: 36px;
|
96 |
+
text-align: center;
|
97 |
+
}
|
98 |
+
}
|
99 |
+
|
100 |
+
@media (max-width: 768px) {
|
101 |
+
.main-content {
|
102 |
+
padding: 10px;
|
103 |
+
}
|
104 |
+
|
105 |
+
.title {
|
106 |
+
font-size: 28px;
|
107 |
+
}
|
108 |
+
|
109 |
+
.board-container {
|
110 |
+
transform: scale(0.8);
|
111 |
+
transform-origin: top center;
|
112 |
+
}
|
113 |
+
|
114 |
+
.sidebar {
|
115 |
+
padding: 15px;
|
116 |
+
}
|
117 |
+
}
|
src/styles/AudioInfoPopup.css
ADDED
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.audio-info-overlay {
|
2 |
+
position: fixed;
|
3 |
+
top: 0;
|
4 |
+
left: 0;
|
5 |
+
right: 0;
|
6 |
+
bottom: 0;
|
7 |
+
background: rgba(0, 0, 0, 0.75);
|
8 |
+
display: flex;
|
9 |
+
align-items: center;
|
10 |
+
justify-content: center;
|
11 |
+
z-index: 2000;
|
12 |
+
}
|
13 |
+
|
14 |
+
.audio-info-popup {
|
15 |
+
background: white;
|
16 |
+
border-radius: 12px;
|
17 |
+
padding: 0;
|
18 |
+
max-width: 600px;
|
19 |
+
max-height: 80vh;
|
20 |
+
width: 90%;
|
21 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
22 |
+
overflow: hidden;
|
23 |
+
display: flex;
|
24 |
+
flex-direction: column;
|
25 |
+
}
|
26 |
+
|
27 |
+
.popup-header {
|
28 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
29 |
+
color: white;
|
30 |
+
padding: 20px;
|
31 |
+
display: flex;
|
32 |
+
justify-content: space-between;
|
33 |
+
align-items: center;
|
34 |
+
}
|
35 |
+
|
36 |
+
.popup-header h2 {
|
37 |
+
margin: 0;
|
38 |
+
font-size: 1.5rem;
|
39 |
+
font-weight: 600;
|
40 |
+
}
|
41 |
+
|
42 |
+
.close-button {
|
43 |
+
background: none;
|
44 |
+
border: none;
|
45 |
+
color: white;
|
46 |
+
font-size: 2rem;
|
47 |
+
cursor: pointer;
|
48 |
+
padding: 0;
|
49 |
+
width: 40px;
|
50 |
+
height: 40px;
|
51 |
+
border-radius: 50%;
|
52 |
+
display: flex;
|
53 |
+
align-items: center;
|
54 |
+
justify-content: center;
|
55 |
+
transition: background-color 0.2s;
|
56 |
+
}
|
57 |
+
|
58 |
+
.close-button:hover {
|
59 |
+
background: rgba(255, 255, 255, 0.2);
|
60 |
+
}
|
61 |
+
|
62 |
+
.popup-content {
|
63 |
+
padding: 24px;
|
64 |
+
overflow-y: auto;
|
65 |
+
flex: 1;
|
66 |
+
}
|
67 |
+
|
68 |
+
.popup-content section {
|
69 |
+
margin-bottom: 24px;
|
70 |
+
}
|
71 |
+
|
72 |
+
.popup-content section:last-child {
|
73 |
+
margin-bottom: 0;
|
74 |
+
}
|
75 |
+
|
76 |
+
.popup-content h3 {
|
77 |
+
color: #333;
|
78 |
+
font-size: 1.25rem;
|
79 |
+
margin: 0 0 12px 0;
|
80 |
+
font-weight: 600;
|
81 |
+
}
|
82 |
+
|
83 |
+
.popup-content h4 {
|
84 |
+
color: #333;
|
85 |
+
font-size: 1.1rem;
|
86 |
+
margin: 0 0 8px 0;
|
87 |
+
font-weight: 600;
|
88 |
+
}
|
89 |
+
|
90 |
+
.popup-content p {
|
91 |
+
color: #555;
|
92 |
+
line-height: 1.6;
|
93 |
+
margin: 0 0 12px 0;
|
94 |
+
}
|
95 |
+
|
96 |
+
.popup-content ul {
|
97 |
+
color: #555;
|
98 |
+
line-height: 1.6;
|
99 |
+
margin: 0;
|
100 |
+
padding-left: 20px;
|
101 |
+
}
|
102 |
+
|
103 |
+
.popup-content li {
|
104 |
+
margin-bottom: 8px;
|
105 |
+
}
|
106 |
+
|
107 |
+
.note-mapping {
|
108 |
+
background: #f8f9fa;
|
109 |
+
border: 1px solid #e9ecef;
|
110 |
+
border-radius: 6px;
|
111 |
+
padding: 12px;
|
112 |
+
margin: 12px 0;
|
113 |
+
font-family: 'Courier New', monospace;
|
114 |
+
}
|
115 |
+
|
116 |
+
.note-mapping div {
|
117 |
+
margin-bottom: 6px;
|
118 |
+
}
|
119 |
+
|
120 |
+
.note-mapping div:last-child {
|
121 |
+
margin-bottom: 0;
|
122 |
+
}
|
123 |
+
|
124 |
+
strong {
|
125 |
+
color: #333;
|
126 |
+
font-weight: 600;
|
127 |
+
}
|
128 |
+
|
129 |
+
@media (max-width: 768px) {
|
130 |
+
.audio-info-popup {
|
131 |
+
width: 95%;
|
132 |
+
max-height: 90vh;
|
133 |
+
}
|
134 |
+
|
135 |
+
.popup-header {
|
136 |
+
padding: 16px;
|
137 |
+
}
|
138 |
+
|
139 |
+
.popup-header h2 {
|
140 |
+
font-size: 1.25rem;
|
141 |
+
}
|
142 |
+
|
143 |
+
.popup-content {
|
144 |
+
padding: 20px;
|
145 |
+
}
|
146 |
+
}
|
src/styles/ChessBoard.css
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.chess-board {
|
2 |
+
position: relative;
|
3 |
+
user-select: none;
|
4 |
+
}
|
5 |
+
|
6 |
+
.board-container {
|
7 |
+
position: relative;
|
8 |
+
width: 600px;
|
9 |
+
height: 600px;
|
10 |
+
}
|
11 |
+
|
12 |
+
.board-squares {
|
13 |
+
position: relative;
|
14 |
+
width: 600px;
|
15 |
+
height: 600px;
|
16 |
+
border: 3px solid #ffffff;
|
17 |
+
border-radius: 4px;
|
18 |
+
}
|
19 |
+
|
20 |
+
.board-border {
|
21 |
+
position: absolute;
|
22 |
+
top: -3px;
|
23 |
+
left: -3px;
|
24 |
+
right: -3px;
|
25 |
+
bottom: -3px;
|
26 |
+
border: 3px solid #ffffff;
|
27 |
+
border-radius: 4px;
|
28 |
+
pointer-events: none;
|
29 |
+
}
|
30 |
+
|
31 |
+
.file-labels {
|
32 |
+
position: absolute;
|
33 |
+
bottom: -30px;
|
34 |
+
left: 0;
|
35 |
+
width: 600px;
|
36 |
+
height: 25px;
|
37 |
+
display: flex;
|
38 |
+
}
|
39 |
+
|
40 |
+
.file-label {
|
41 |
+
width: 75px;
|
42 |
+
height: 25px;
|
43 |
+
display: flex;
|
44 |
+
flex-direction: column;
|
45 |
+
align-items: center;
|
46 |
+
justify-content: center;
|
47 |
+
color: #ffffff;
|
48 |
+
font-weight: bold;
|
49 |
+
font-size: 14px;
|
50 |
+
}
|
51 |
+
|
52 |
+
.file-letter {
|
53 |
+
font-size: 16px;
|
54 |
+
margin-bottom: 2px;
|
55 |
+
}
|
56 |
+
|
57 |
+
.file-note {
|
58 |
+
font-size: 11px;
|
59 |
+
color: #cccccc;
|
60 |
+
font-weight: normal;
|
61 |
+
}
|
62 |
+
|
63 |
+
.rank-labels {
|
64 |
+
position: absolute;
|
65 |
+
left: -50px;
|
66 |
+
top: 0;
|
67 |
+
width: 45px;
|
68 |
+
height: 600px;
|
69 |
+
display: flex;
|
70 |
+
flex-direction: column;
|
71 |
+
}
|
72 |
+
|
73 |
+
.rank-label {
|
74 |
+
width: 45px;
|
75 |
+
height: 75px;
|
76 |
+
display: flex;
|
77 |
+
flex-direction: column;
|
78 |
+
align-items: center;
|
79 |
+
justify-content: center;
|
80 |
+
color: #ffffff;
|
81 |
+
font-weight: bold;
|
82 |
+
font-size: 14px;
|
83 |
+
}
|
84 |
+
|
85 |
+
.rank-number {
|
86 |
+
font-size: 16px;
|
87 |
+
margin-bottom: 2px;
|
88 |
+
}
|
89 |
+
|
90 |
+
.rank-octave {
|
91 |
+
font-size: 10px;
|
92 |
+
color: #cccccc;
|
93 |
+
font-weight: normal;
|
94 |
+
}
|
95 |
+
|
96 |
+
.dragged-piece {
|
97 |
+
pointer-events: none;
|
98 |
+
z-index: 1000;
|
99 |
+
}
|
src/styles/ChessPiece.css
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.chess-piece {
|
2 |
+
display: flex;
|
3 |
+
align-items: center;
|
4 |
+
justify-content: center;
|
5 |
+
cursor: inherit;
|
6 |
+
transition: opacity 0.2s ease;
|
7 |
+
}
|
8 |
+
|
9 |
+
.chess-piece.dragging {
|
10 |
+
opacity: 0.5;
|
11 |
+
}
|
12 |
+
|
13 |
+
.piece-image {
|
14 |
+
width: 100%;
|
15 |
+
height: 100%;
|
16 |
+
object-fit: contain;
|
17 |
+
pointer-events: none;
|
18 |
+
user-select: none;
|
19 |
+
-webkit-user-drag: none;
|
20 |
+
}
|
21 |
+
|
22 |
+
.piece-symbol {
|
23 |
+
font-family: 'Chess Merida', 'Chess Alpha', serif;
|
24 |
+
font-weight: bold;
|
25 |
+
display: flex;
|
26 |
+
align-items: center;
|
27 |
+
justify-content: center;
|
28 |
+
pointer-events: none;
|
29 |
+
user-select: none;
|
30 |
+
}
|
src/styles/ChessSquare.css
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.chess-square {
|
2 |
+
position: absolute;
|
3 |
+
cursor: pointer;
|
4 |
+
transition: all 0.2s ease;
|
5 |
+
border-radius: 2px;
|
6 |
+
}
|
7 |
+
|
8 |
+
.chess-square.light {
|
9 |
+
background-color: #f0d9b5;
|
10 |
+
}
|
11 |
+
|
12 |
+
.chess-square.dark {
|
13 |
+
background-color: #b58863;
|
14 |
+
}
|
15 |
+
|
16 |
+
.chess-square.selected {
|
17 |
+
background-color: #ff6b6b !important;
|
18 |
+
box-shadow: inset 0 0 0 3px rgba(255, 107, 107, 0.8);
|
19 |
+
}
|
20 |
+
|
21 |
+
.chess-square.legal-move {
|
22 |
+
background-color: rgba(144, 238, 144, 0.6) !important;
|
23 |
+
}
|
24 |
+
|
25 |
+
.chess-square.drop-target {
|
26 |
+
background-color: rgba(255, 255, 0, 0.4) !important;
|
27 |
+
}
|
28 |
+
|
29 |
+
.chess-square:hover {
|
30 |
+
filter: brightness(1.1);
|
31 |
+
}
|
32 |
+
|
33 |
+
.piece-container {
|
34 |
+
width: 100%;
|
35 |
+
height: 100%;
|
36 |
+
display: flex;
|
37 |
+
align-items: center;
|
38 |
+
justify-content: center;
|
39 |
+
cursor: grab;
|
40 |
+
}
|
41 |
+
|
42 |
+
.piece-container:active {
|
43 |
+
cursor: grabbing;
|
44 |
+
}
|
45 |
+
|
46 |
+
.legal-move-indicator {
|
47 |
+
position: absolute;
|
48 |
+
top: 50%;
|
49 |
+
left: 50%;
|
50 |
+
transform: translate(-50%, -50%);
|
51 |
+
width: 16px;
|
52 |
+
height: 16px;
|
53 |
+
background-color: rgba(0, 128, 0, 0.8);
|
54 |
+
border-radius: 50%;
|
55 |
+
pointer-events: none;
|
56 |
+
}
|
57 |
+
|
58 |
+
.chess-square.legal-move .legal-move-indicator {
|
59 |
+
background-color: rgba(0, 80, 0, 0.9);
|
60 |
+
}
|
src/styles/GameControls.css
ADDED
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.game-controls {
|
2 |
+
display: flex;
|
3 |
+
flex-direction: column;
|
4 |
+
gap: 20px;
|
5 |
+
}
|
6 |
+
|
7 |
+
.game-status {
|
8 |
+
font-size: 20px;
|
9 |
+
font-weight: bold;
|
10 |
+
color: #ffffff;
|
11 |
+
text-align: center;
|
12 |
+
min-height: 30px;
|
13 |
+
display: flex;
|
14 |
+
align-items: center;
|
15 |
+
justify-content: center;
|
16 |
+
}
|
17 |
+
|
18 |
+
.control-buttons {
|
19 |
+
display: flex;
|
20 |
+
flex-direction: column;
|
21 |
+
gap: 15px;
|
22 |
+
}
|
23 |
+
|
24 |
+
.main-button {
|
25 |
+
background-color: #646464;
|
26 |
+
}
|
27 |
+
|
28 |
+
.color-button {
|
29 |
+
background-color: #646464;
|
30 |
+
}
|
31 |
+
|
32 |
+
.audio-button {
|
33 |
+
background-color: #646464;
|
34 |
+
}
|
35 |
+
|
36 |
+
.volume-controls {
|
37 |
+
margin-top: 20px;
|
38 |
+
padding: 20px;
|
39 |
+
background-color: rgba(255, 255, 255, 0.05);
|
40 |
+
border-radius: 8px;
|
41 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
42 |
+
}
|
43 |
+
|
44 |
+
.volume-controls-title {
|
45 |
+
margin: 0 0 16px 0;
|
46 |
+
font-size: 16px;
|
47 |
+
font-weight: 600;
|
48 |
+
color: #ffffff;
|
49 |
+
text-align: center;
|
50 |
+
}
|
51 |
+
|
52 |
+
.model-selector {
|
53 |
+
display: flex;
|
54 |
+
flex-direction: column;
|
55 |
+
gap: 8px;
|
56 |
+
padding: 12px;
|
57 |
+
background-color: rgba(255, 255, 255, 0.05);
|
58 |
+
border-radius: 8px;
|
59 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
60 |
+
}
|
61 |
+
|
62 |
+
.model-selector label {
|
63 |
+
font-size: 14px;
|
64 |
+
font-weight: 600;
|
65 |
+
color: #ffffff;
|
66 |
+
}
|
67 |
+
|
68 |
+
.model-dropdown {
|
69 |
+
padding: 8px 12px;
|
70 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
71 |
+
border-radius: 6px;
|
72 |
+
background-color: rgba(255, 255, 255, 0.1);
|
73 |
+
color: #ffffff;
|
74 |
+
font-size: 14px;
|
75 |
+
cursor: pointer;
|
76 |
+
transition: all 0.2s ease;
|
77 |
+
}
|
78 |
+
|
79 |
+
.model-dropdown:hover:not(:disabled) {
|
80 |
+
background-color: rgba(255, 255, 255, 0.15);
|
81 |
+
border-color: rgba(255, 255, 255, 0.3);
|
82 |
+
}
|
83 |
+
|
84 |
+
.model-dropdown:disabled {
|
85 |
+
opacity: 0.5;
|
86 |
+
cursor: not-allowed;
|
87 |
+
}
|
88 |
+
|
89 |
+
.model-dropdown option {
|
90 |
+
background-color: #2a2a2a;
|
91 |
+
color: #ffffff;
|
92 |
+
}
|