scaffold
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
# JDClone
|
||||
|
||||
Ovo's attempt at something beautiful, pls do not touch for now, tnxx
|
||||
react + vite
|
||||
scss
|
||||
typescript
|
||||
|
||||
connect to local server 127.0.0.1
|
||||
python
|
||||
runs AI inference
|
||||
364
example_client.html
Normal file
364
example_client.html
Normal file
@@ -0,0 +1,364 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pose Detection Visualization</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #2c3e50;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.panel {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.video-container {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
.video-feed {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
#skeletonCanvas {
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.data-container {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
}
|
||||
.raw-data {
|
||||
background-color: #282c34;
|
||||
color: #abb2bf;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.controls {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
button {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.connection-status {
|
||||
display: inline-block;
|
||||
margin-left: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.connected {
|
||||
color: #27ae60;
|
||||
}
|
||||
.disconnected {
|
||||
color: #e74c3c;
|
||||
}
|
||||
select {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
margin-right: 10px;
|
||||
}
|
||||
footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-container">
|
||||
<h1>Pose Detection Visualization</h1>
|
||||
|
||||
<div class="controls">
|
||||
<button id="connectBtn">Connect to Server</button>
|
||||
<select id="colorTheme">
|
||||
<option value="default">Default Colors</option>
|
||||
<option value="neon">Neon</option>
|
||||
<option value="pastel">Pastel</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
</select>
|
||||
<span id="connectionStatus" class="connection-status disconnected">Disconnected</span>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="panel video-container">
|
||||
<h2>Server Video Feeds</h2>
|
||||
<h3>Raw Video Feed</h3>
|
||||
<img id="rawVideoFeed" class="video-feed" src="http://localhost:5000/video_feed" alt="Raw Video Feed" />
|
||||
|
||||
<h3>Annotated Video Feed</h3>
|
||||
<img id="annotatedVideoFeed" class="video-feed" src="http://localhost:5000/video_feed/annotated" alt="Annotated Video Feed" />
|
||||
</div>
|
||||
|
||||
<div class="panel data-container">
|
||||
<h2>Raw Landmark Data</h2>
|
||||
<pre id="rawData" class="raw-data">Waiting for data...</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Custom Skeleton Visualization</h2>
|
||||
<div class="canvas-container">
|
||||
<canvas id="skeletonCanvas" width="640" height="480"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Powered by MediaPipe, OpenCV, and Socket.IO</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||
<script>
|
||||
// DOM Elements
|
||||
const rawDataElement = document.getElementById('rawData');
|
||||
const skeletonCanvas = document.getElementById('skeletonCanvas');
|
||||
const ctx = skeletonCanvas.getContext('2d');
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const colorThemeSelect = document.getElementById('colorTheme');
|
||||
|
||||
// Set canvas dimensions
|
||||
const canvasWidth = skeletonCanvas.width;
|
||||
const canvasHeight = skeletonCanvas.height;
|
||||
|
||||
// Socket connection
|
||||
let socket;
|
||||
let isConnected = false;
|
||||
let landmarksData = null;
|
||||
|
||||
// Color themes
|
||||
const colorThemes = {
|
||||
default: {
|
||||
background: '#f8f9fa',
|
||||
joints: '#e74c3c',
|
||||
torso: '#e74c3c',
|
||||
arms: '#3498db',
|
||||
legs: '#2ecc71',
|
||||
shoulders: '#f39c12',
|
||||
head: '#9b59b6'
|
||||
},
|
||||
neon: {
|
||||
background: '#121212',
|
||||
joints: '#ff00ff',
|
||||
torso: '#ff0099',
|
||||
arms: '#00ffff',
|
||||
legs: '#00ff00',
|
||||
shoulders: '#ffff00',
|
||||
head: '#ff9900'
|
||||
},
|
||||
pastel: {
|
||||
background: '#f8f9fa',
|
||||
joints: '#f08080',
|
||||
torso: '#f08080',
|
||||
arms: '#98d8c8',
|
||||
legs: '#b5ead7',
|
||||
shoulders: '#ffdac1',
|
||||
head: '#c7ceea'
|
||||
},
|
||||
grayscale: {
|
||||
background: '#ffffff',
|
||||
joints: '#333333',
|
||||
torso: '#444444',
|
||||
arms: '#666666',
|
||||
legs: '#888888',
|
||||
shoulders: '#aaaaaa',
|
||||
head: '#555555'
|
||||
}
|
||||
};
|
||||
|
||||
let currentColorTheme = colorThemes.default;
|
||||
|
||||
// Connect to the Socket.IO server
|
||||
function connectToServer() {
|
||||
if (isConnected) {
|
||||
socket.disconnect();
|
||||
isConnected = false;
|
||||
connectBtn.textContent = 'Connect to Server';
|
||||
connectionStatus.textContent = 'Disconnected';
|
||||
connectionStatus.className = 'connection-status disconnected';
|
||||
return;
|
||||
}
|
||||
|
||||
socket = io('http://localhost:5000');
|
||||
|
||||
socket.on('connect', function() {
|
||||
isConnected = true;
|
||||
connectBtn.textContent = 'Disconnect';
|
||||
connectionStatus.textContent = 'Connected';
|
||||
connectionStatus.className = 'connection-status connected';
|
||||
console.log('Connected to server');
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
isConnected = false;
|
||||
connectBtn.textContent = 'Connect to Server';
|
||||
connectionStatus.textContent = 'Disconnected';
|
||||
connectionStatus.className = 'connection-status disconnected';
|
||||
console.log('Disconnected from server');
|
||||
});
|
||||
|
||||
socket.on('landmarks', function(data) {
|
||||
landmarksData = JSON.parse(data);
|
||||
|
||||
// Update raw data display
|
||||
rawDataElement.textContent = JSON.stringify(landmarksData, null, 2);
|
||||
|
||||
// Draw skeleton
|
||||
drawSkeleton(landmarksData);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw skeleton on canvas
|
||||
function drawSkeleton(data) {
|
||||
// Clear canvas
|
||||
ctx.fillStyle = currentColorTheme.background;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
if (!data || !data.landmarks || data.landmarks.length === 0) {
|
||||
// No landmarks to draw
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('No pose detected', canvasWidth/2, canvasHeight/2);
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = data.image_width;
|
||||
const imageHeight = data.image_height;
|
||||
|
||||
// Scale factors to map coordinates to canvas
|
||||
const scaleX = canvasWidth / imageWidth;
|
||||
const scaleY = canvasHeight / imageHeight;
|
||||
|
||||
// Draw connections
|
||||
if (data.connections) {
|
||||
data.connections.forEach(connection => {
|
||||
const startLandmark = data.landmarks.find(l => l.idx === connection.start);
|
||||
const endLandmark = data.landmarks.find(l => l.idx === connection.end);
|
||||
|
||||
if (startLandmark && endLandmark) {
|
||||
// Get connection color based on body part
|
||||
let color = currentColorTheme.joints;
|
||||
|
||||
// Determine which body part this connection belongs to
|
||||
// Shoulders (connection between shoulder landmarks)
|
||||
if ((connection.start === 11 && connection.end === 12) ||
|
||||
(connection.start === 12 && connection.end === 11)) {
|
||||
color = currentColorTheme.shoulders;
|
||||
}
|
||||
// Torso (connections between shoulders and hips)
|
||||
else if ((connection.start >= 11 && connection.start <= 12 && connection.end >= 23 && connection.end <= 24) ||
|
||||
(connection.start >= 23 && connection.start <= 24 && connection.end >= 11 && connection.end <= 12) ||
|
||||
(connection.start === 23 && connection.end === 24) ||
|
||||
(connection.start === 24 && connection.end === 23)) {
|
||||
color = currentColorTheme.torso;
|
||||
}
|
||||
// Arms (connections involving elbows and wrists)
|
||||
else if ((connection.start >= 11 && connection.start <= 16) ||
|
||||
(connection.end >= 11 && connection.end <= 16)) {
|
||||
color = currentColorTheme.arms;
|
||||
}
|
||||
// Legs (connections involving knees and ankles)
|
||||
else if ((connection.start >= 23 && connection.start <= 32) ||
|
||||
(connection.end >= 23 && connection.end <= 32)) {
|
||||
color = currentColorTheme.legs;
|
||||
}
|
||||
|
||||
// Draw line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(startLandmark.x * scaleX, startLandmark.y * scaleY);
|
||||
ctx.lineTo(endLandmark.x * scaleX, endLandmark.y * scaleY);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Draw landmarks
|
||||
data.landmarks.forEach(landmark => {
|
||||
const x = landmark.x * scaleX;
|
||||
const y = landmark.y * scaleY;
|
||||
|
||||
// Skip drawing low visibility landmarks
|
||||
if (landmark.visibility < 0.5) return;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 5, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = currentColorTheme.joints;
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
connectBtn.addEventListener('click', connectToServer);
|
||||
|
||||
colorThemeSelect.addEventListener('change', function() {
|
||||
const theme = this.value;
|
||||
currentColorTheme = colorThemes[theme];
|
||||
|
||||
// Redraw if we have data
|
||||
if (landmarksData) {
|
||||
drawSkeleton(landmarksData);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-connect on page load
|
||||
window.addEventListener('load', function() {
|
||||
// Draw empty skeleton
|
||||
ctx.fillStyle = currentColorTheme.background;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Connect to see skeleton visualization', canvasWidth/2, canvasHeight/2);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Just Dance Clone - Dance to your favorite songs with pose tracking" />
|
||||
<title>Just Dance Clone</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
jd-clone/.gitignore
vendored
Normal file
24
jd-clone/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
54
jd-clone/README.md
Normal file
54
jd-clone/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
jd-clone/eslint.config.js
Normal file
28
jd-clone/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
jd-clone/index.html
Normal file
13
jd-clone/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4996
jd-clone/package-lock.json
generated
Normal file
4996
jd-clone/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
jd-clone/package.json
Normal file
37
jd-clone/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "jd-clone",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/howler": "^2.2.12",
|
||||
"framer-motion": "^12.9.4",
|
||||
"howler": "^2.2.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router-dom": "^7.5.3",
|
||||
"sass": "^1.87.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"vite": "^6.3.1"
|
||||
}
|
||||
}
|
||||
1
jd-clone/public/vite.svg
Normal file
1
jd-clone/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
53
jd-clone/src/App.tsx
Normal file
53
jd-clone/src/App.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import useAppStore from './store/app-store';
|
||||
import poseService from './services/pose-service';
|
||||
import './assets/styles/global.scss';
|
||||
|
||||
// Import pages (we'll create these later)
|
||||
const HomePage = React.lazy(() => import('./pages/home/HomePage'));
|
||||
const GameSetupPage = React.lazy(() => import('./pages/setup/GameSetupPage'));
|
||||
const GameplayPage = React.lazy(() => import('./pages/gameplay/GameplayPage'));
|
||||
const ResultsPage = React.lazy(() => import('./pages/results/ResultsPage'));
|
||||
const SettingsPage = React.lazy(() => import('./pages/settings/SettingsPage'));
|
||||
|
||||
function App() {
|
||||
const setApiConnected = useAppStore(state => state.setApiConnected);
|
||||
|
||||
// Connect to pose API on app start
|
||||
useEffect(() => {
|
||||
const connectToPoseApi = async () => {
|
||||
try {
|
||||
await poseService.connect();
|
||||
setApiConnected(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to pose API:', error);
|
||||
setApiConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
connectToPoseApi();
|
||||
|
||||
// Disconnect when the app is closed
|
||||
return () => {
|
||||
poseService.disconnect();
|
||||
};
|
||||
}, [setApiConnected]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<React.Suspense fallback={<div className="loading">Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/setup" element={<GameSetupPage />} />
|
||||
<Route path="/play" element={<GameplayPage />} />
|
||||
<Route path="/results" element={<ResultsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<HomePage />} />
|
||||
</Routes>
|
||||
</React.Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
72
jd-clone/src/assets/styles/global.scss
Normal file
72
jd-clone/src/assets/styles/global.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
@use "sass:color";
|
||||
@use 'reset.scss';
|
||||
@use 'variables.scss';
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Poppins:wght@400;500;600;700;800&display=swap');
|
||||
|
||||
body {
|
||||
font-family: variables.$font-main;
|
||||
background-color: variables.$background-dark;
|
||||
color: variables.$text-light;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: variables.$font-display;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 variables.$spacing-md;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid variables.$accent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity variables.$transition-normal;
|
||||
}
|
||||
|
||||
.fade-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fade-exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity variables.$transition-normal;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: variables.$background-light;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: variables.$primary;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color.adjust(variables.$primary, $lightness: -10%);
|
||||
}
|
||||
47
jd-clone/src/assets/styles/reset.scss
Normal file
47
jd-clone/src/assets/styles/reset.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
/* Reset and base styles */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
img, picture, video, canvas, svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p, h1, h2, h3, h4, h5, h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#root {
|
||||
isolation: isolate;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
37
jd-clone/src/assets/styles/variables.scss
Normal file
37
jd-clone/src/assets/styles/variables.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
// Colors
|
||||
$primary: #ff0055;
|
||||
$secondary: #00ccff;
|
||||
$accent: #ffcc00;
|
||||
$background-dark: #121212;
|
||||
$background-light: #1e1e1e;
|
||||
$text-light: #ffffff;
|
||||
$text-dark: #121212;
|
||||
|
||||
// Fonts
|
||||
$font-main: 'Montserrat', sans-serif;
|
||||
$font-display: 'Poppins', sans-serif;
|
||||
|
||||
// Breakpoints
|
||||
$breakpoint-sm: 576px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 992px;
|
||||
$breakpoint-xl: 1200px;
|
||||
|
||||
// Spacing
|
||||
$spacing-xs: 0.25rem;
|
||||
$spacing-sm: 0.5rem;
|
||||
$spacing-md: 1rem;
|
||||
$spacing-lg: 1.5rem;
|
||||
$spacing-xl: 2.5rem;
|
||||
|
||||
// Animation
|
||||
$transition-fast: 0.2s ease;
|
||||
$transition-normal: 0.3s ease;
|
||||
$transition-slow: 0.5s ease;
|
||||
|
||||
// Z-index layers
|
||||
$z-background: 0;
|
||||
$z-default: 1;
|
||||
$z-foreground: 10;
|
||||
$z-overlay: 100;
|
||||
$z-modal: 1000;
|
||||
136
jd-clone/src/components/common/Button/Button.scss
Normal file
136
jd-clone/src/components/common/Button/Button.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
@use 'sass:color';
|
||||
@use '../../../assets/styles/variables.scss' as *;
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 600;
|
||||
transition: all $transition-normal;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: $primary;
|
||||
color: $text-light;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color.adjust($primary, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background-color: $secondary;
|
||||
color: $text-dark;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color.adjust($secondary, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
|
||||
&--accent {
|
||||
background-color: $accent;
|
||||
color: $text-dark;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color.adjust($accent, $lightness: -10%);
|
||||
}
|
||||
}
|
||||
|
||||
&--outline {
|
||||
background-color: transparent;
|
||||
border: 2px solid $primary;
|
||||
color: $primary;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba($primary, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&--text {
|
||||
background-color: transparent;
|
||||
color: $primary;
|
||||
padding: 0.75rem;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba($primary, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&--small {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&--large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
// Full width
|
||||
&--full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Icon styles
|
||||
&--with-icon {
|
||||
.button__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.button--icon-right {
|
||||
.button__text {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.button--icon-right) {
|
||||
.button__text {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ripple animation
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle, #fff 10%, transparent 10.01%);
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50%;
|
||||
transform: scale(10, 10);
|
||||
opacity: 0;
|
||||
transition: transform 0.5s, opacity 0.8s;
|
||||
}
|
||||
|
||||
&:active::after {
|
||||
transform: scale(0, 0);
|
||||
opacity: 0.3;
|
||||
transition: 0s;
|
||||
}
|
||||
}
|
||||
46
jd-clone/src/components/common/Button/Button.tsx
Normal file
46
jd-clone/src/components/common/Button/Button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import './Button.scss';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'accent' | 'outline' | 'text';
|
||||
export type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
fullWidth = false,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const buttonClasses = [
|
||||
'button',
|
||||
`button--${variant}`,
|
||||
`button--${size}`,
|
||||
fullWidth ? 'button--full-width' : '',
|
||||
icon ? 'button--with-icon' : '',
|
||||
icon && iconPosition === 'right' ? 'button--icon-right' : '',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<button className={buttonClasses} {...props}>
|
||||
{icon && iconPosition === 'left' && <span className="button__icon">{icon}</span>}
|
||||
<span className="button__text">{children}</span>
|
||||
{icon && iconPosition === 'right' && <span className="button__icon">{icon}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
2
jd-clone/src/components/common/Button/index.ts
Normal file
2
jd-clone/src/components/common/Button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default, Button } from './Button';
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||
@@ -0,0 +1,18 @@
|
||||
.button-with-controller {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&__button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
right: -12px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
padding: 4px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Button, ButtonProps } from '../Button';
|
||||
import ControllerButton, { ButtonType } from '../ControllerButton';
|
||||
import useAppStore from '../../../store/app-store';
|
||||
import './ButtonWithController.scss';
|
||||
|
||||
interface ButtonWithControllerProps extends ButtonProps {
|
||||
controllerButton: ButtonType;
|
||||
}
|
||||
|
||||
export function ButtonWithController({
|
||||
controllerButton,
|
||||
children,
|
||||
className = '',
|
||||
...buttonProps
|
||||
}: ButtonWithControllerProps) {
|
||||
const inputDevice = useAppStore(state => state.settings.input);
|
||||
const showControllerHint = inputDevice === 'gamepad';
|
||||
|
||||
const classes = [
|
||||
'button-with-controller',
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<Button className="button-with-controller__button" {...buttonProps}>
|
||||
{children}
|
||||
</Button>
|
||||
{showControllerHint && (
|
||||
<div className="button-with-controller__hint">
|
||||
<ControllerButton
|
||||
button={controllerButton}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ButtonWithController;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ButtonWithController } from './ButtonWithController';
|
||||
export { ButtonWithController };
|
||||
export default ButtonWithController;
|
||||
@@ -0,0 +1,51 @@
|
||||
.controller-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: sans-serif;
|
||||
font-weight: 500;
|
||||
|
||||
&__icon {
|
||||
display: block;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&--small {
|
||||
.controller-button__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.controller-button__label {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--medium {
|
||||
.controller-button__icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.controller-button__label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&--large {
|
||||
.controller-button__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.controller-button__label {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import './ControllerButton.scss';
|
||||
import useAppStore from '../../../store/app-store';
|
||||
import useControllerDetection from '../../../hooks/useControllerDetection';
|
||||
|
||||
export type ButtonType = 'A' | 'B' | 'X' | 'Y' | 'LB' | 'RB' | 'LT' | 'RT' | 'start' | 'back' | 'dpad-up' | 'dpad-down' | 'dpad-left' | 'dpad-right';
|
||||
|
||||
export interface ControllerButtonProps {
|
||||
button: ButtonType;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ControllerButton({
|
||||
button,
|
||||
size = 'medium',
|
||||
label,
|
||||
className = '',
|
||||
}: ControllerButtonProps) {
|
||||
const inputDevice = useAppStore(state => state.settings.input);
|
||||
const { controllerType, getButtonMapping } = useControllerDetection();
|
||||
|
||||
const getButtonIcon = () => {
|
||||
// For a real implementation, you would use actual icon URLs
|
||||
// This is a placeholder using a hypothetical free gamepad icons API
|
||||
const type = inputDevice === 'keyboard' ? 'keyboard' : (controllerType || 'generic');
|
||||
return `https://gamepadicons.download/icons/${type}/${button.toLowerCase()}.svg`;
|
||||
};
|
||||
|
||||
// Get the correct button label based on controller type
|
||||
const mappedButton = getButtonMapping(button);
|
||||
|
||||
const buttonClasses = [
|
||||
'controller-button',
|
||||
`controller-button--${size}`,
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={buttonClasses}>
|
||||
<img
|
||||
src={getButtonIcon()}
|
||||
alt={`${mappedButton} button`}
|
||||
className="controller-button__icon"
|
||||
/>
|
||||
{label && <span className="controller-button__label">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ControllerButton;
|
||||
4
jd-clone/src/components/common/ControllerButton/index.ts
Normal file
4
jd-clone/src/components/common/ControllerButton/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ControllerButton } from './ControllerButton';
|
||||
export { ControllerButton };
|
||||
export type { ButtonType, ControllerButtonProps } from './ControllerButton';
|
||||
export default ControllerButton;
|
||||
@@ -0,0 +1,6 @@
|
||||
.pose-renderer {
|
||||
display: block;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
112
jd-clone/src/components/game/PoseRenderer/PoseRenderer.tsx
Normal file
112
jd-clone/src/components/game/PoseRenderer/PoseRenderer.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { PoseData, PoseLandmark } from '../../../types';
|
||||
import './PoseRenderer.scss';
|
||||
|
||||
interface PoseRendererProps {
|
||||
poseData: PoseData | null;
|
||||
showConnections?: boolean;
|
||||
showPoints?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
pointColor?: string;
|
||||
connectionColor?: string;
|
||||
pointSize?: number;
|
||||
lineWidth?: number;
|
||||
mirror?: boolean;
|
||||
}
|
||||
|
||||
export function PoseRenderer({
|
||||
poseData,
|
||||
showConnections = true,
|
||||
showPoints = true,
|
||||
width = 640,
|
||||
height = 480,
|
||||
className = '',
|
||||
pointColor = '#00CCFF',
|
||||
connectionColor = '#FF0055',
|
||||
pointSize = 8,
|
||||
lineWidth = 4,
|
||||
mirror = true,
|
||||
}: PoseRendererProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Draw the pose on the canvas
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !poseData) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear the canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Calculate scale factors if the data dimensions don't match the canvas
|
||||
const scaleX = canvas.width / poseData.image_width;
|
||||
const scaleY = canvas.height / poseData.image_height;
|
||||
|
||||
// Draw connections first (so points are on top)
|
||||
if (showConnections && poseData.connections && poseData.landmarks) {
|
||||
ctx.strokeStyle = connectionColor;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
poseData.connections.forEach(connection => {
|
||||
const start = poseData.landmarks[connection.start];
|
||||
const end = poseData.landmarks[connection.end];
|
||||
|
||||
if (start && end && start.visibility > 0.5 && end.visibility > 0.5) {
|
||||
ctx.beginPath();
|
||||
|
||||
// Apply scaling and mirroring if needed
|
||||
let startX = start.x * scaleX;
|
||||
const startY = start.y * scaleY;
|
||||
let endX = end.x * scaleX;
|
||||
const endY = end.y * scaleY;
|
||||
|
||||
if (mirror) {
|
||||
startX = canvas.width - startX;
|
||||
endX = canvas.width - endX;
|
||||
}
|
||||
|
||||
ctx.moveTo(startX, startY);
|
||||
ctx.lineTo(endX, endY);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Draw the landmark points
|
||||
if (showPoints && poseData.landmarks) {
|
||||
ctx.fillStyle = pointColor;
|
||||
|
||||
poseData.landmarks.forEach(landmark => {
|
||||
if (landmark.visibility > 0.5) {
|
||||
// Apply scaling and mirroring if needed
|
||||
let x = landmark.x * scaleX;
|
||||
const y = landmark.y * scaleY;
|
||||
|
||||
if (mirror) {
|
||||
x = canvas.width - x;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, pointSize, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [poseData, showConnections, showPoints, width, height, pointColor, connectionColor, pointSize, lineWidth, mirror]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`pose-renderer ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PoseRenderer;
|
||||
1
jd-clone/src/components/game/PoseRenderer/index.ts
Normal file
1
jd-clone/src/components/game/PoseRenderer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, PoseRenderer } from './PoseRenderer';
|
||||
175
jd-clone/src/hooks/useControllerDetection.ts
Normal file
175
jd-clone/src/hooks/useControllerDetection.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useAppStore from '../store/app-store';
|
||||
|
||||
interface ControllerInfo {
|
||||
isConnected: boolean;
|
||||
controllerName: string | null;
|
||||
controllerType: 'xbox' | 'playstation' | 'nintendo' | 'generic' | null;
|
||||
}
|
||||
|
||||
export function useControllerDetection() {
|
||||
const [controllerInfo, setControllerInfo] = useState<ControllerInfo>({
|
||||
isConnected: false,
|
||||
controllerName: null,
|
||||
controllerType: null
|
||||
});
|
||||
|
||||
const inputDevice = useAppStore(state => state.settings.input);
|
||||
const setInputDevice = useAppStore(state => state.setInputDevice);
|
||||
|
||||
useEffect(() => {
|
||||
// Function to detect controller type based on ID
|
||||
const detectControllerType = (id: string): 'xbox' | 'playstation' | 'nintendo' | 'generic' => {
|
||||
id = id.toLowerCase();
|
||||
|
||||
if (id.includes('xbox') || id.includes('microsoft')) {
|
||||
return 'xbox';
|
||||
} else if (id.includes('playstation') || id.includes('sony') || id.includes('dualsock') || id.includes('dualsense')) {
|
||||
return 'playstation';
|
||||
} else if (id.includes('nintendo') || id.includes('joy-con') || id.includes('pro controller')) {
|
||||
return 'nintendo';
|
||||
} else {
|
||||
return 'generic';
|
||||
}
|
||||
};
|
||||
|
||||
const handleGamepadConnected = (e: GamepadEvent) => {
|
||||
console.log('Gamepad connected:', e.gamepad);
|
||||
const type = detectControllerType(e.gamepad.id);
|
||||
|
||||
setControllerInfo({
|
||||
isConnected: true,
|
||||
controllerName: e.gamepad.id,
|
||||
controllerType: type
|
||||
});
|
||||
|
||||
setInputDevice('gamepad');
|
||||
};
|
||||
|
||||
const handleGamepadDisconnected = (e: GamepadEvent) => {
|
||||
console.log('Gamepad disconnected:', e.gamepad);
|
||||
|
||||
setControllerInfo({
|
||||
isConnected: false,
|
||||
controllerName: null,
|
||||
controllerType: null
|
||||
});
|
||||
|
||||
// Only switch to keyboard if currently using gamepad
|
||||
if (inputDevice === 'gamepad') {
|
||||
setInputDevice('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Check for already connected gamepads
|
||||
const checkExistingGamepads = () => {
|
||||
const gamepads = navigator.getGamepads();
|
||||
|
||||
for (const gamepad of gamepads) {
|
||||
if (gamepad) {
|
||||
const type = detectControllerType(gamepad.id);
|
||||
|
||||
setControllerInfo({
|
||||
isConnected: true,
|
||||
controllerName: gamepad.id,
|
||||
controllerType: type
|
||||
});
|
||||
|
||||
setInputDevice('gamepad');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkExistingGamepads();
|
||||
|
||||
// Add event listeners
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
|
||||
// Set up polling to detect changes in controller state
|
||||
const intervalId = setInterval(() => {
|
||||
const gamepads = navigator.getGamepads();
|
||||
let anyConnected = false;
|
||||
|
||||
for (const gamepad of gamepads) {
|
||||
if (gamepad) {
|
||||
anyConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyConnected !== controllerInfo.isConnected) {
|
||||
if (anyConnected) {
|
||||
checkExistingGamepads();
|
||||
} else {
|
||||
setControllerInfo({
|
||||
isConnected: false,
|
||||
controllerName: null,
|
||||
controllerType: null
|
||||
});
|
||||
|
||||
if (inputDevice === 'gamepad') {
|
||||
setInputDevice('keyboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [controllerInfo.isConnected, inputDevice, setInputDevice]);
|
||||
|
||||
// Helper function to get controller button mapping for current controller type
|
||||
const getButtonMapping = (button: string): string => {
|
||||
// Map of generic button names to specific controller button names
|
||||
const buttonMappings: Record<string, Record<string, string>> = {
|
||||
xbox: {
|
||||
'A': 'A',
|
||||
'B': 'B',
|
||||
'X': 'X',
|
||||
'Y': 'Y',
|
||||
'start': 'Menu',
|
||||
'back': 'View'
|
||||
},
|
||||
playstation: {
|
||||
'A': 'Cross',
|
||||
'B': 'Circle',
|
||||
'X': 'Square',
|
||||
'Y': 'Triangle',
|
||||
'start': 'Options',
|
||||
'back': 'Share'
|
||||
},
|
||||
nintendo: {
|
||||
'A': 'B',
|
||||
'B': 'A',
|
||||
'X': 'Y',
|
||||
'Y': 'X',
|
||||
'start': '+',
|
||||
'back': '-'
|
||||
},
|
||||
generic: {
|
||||
'A': 'A',
|
||||
'B': 'B',
|
||||
'X': 'X',
|
||||
'Y': 'Y',
|
||||
'start': 'Start',
|
||||
'back': 'Select'
|
||||
}
|
||||
};
|
||||
|
||||
const type = controllerInfo.controllerType || 'generic';
|
||||
return buttonMappings[type][button] || button;
|
||||
};
|
||||
|
||||
return {
|
||||
...controllerInfo,
|
||||
getButtonMapping
|
||||
};
|
||||
}
|
||||
|
||||
export default useControllerDetection;
|
||||
190
jd-clone/src/hooks/useInputDetection.ts
Normal file
190
jd-clone/src/hooks/useInputDetection.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import useAppStore from '../store/app-store';
|
||||
import { InputDevice } from '../types';
|
||||
|
||||
interface InputState {
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
action: boolean;
|
||||
back: boolean;
|
||||
start: boolean;
|
||||
}
|
||||
|
||||
const initialInputState: InputState = {
|
||||
up: false,
|
||||
down: false,
|
||||
left: false,
|
||||
right: false,
|
||||
action: false,
|
||||
back: false,
|
||||
start: false,
|
||||
};
|
||||
|
||||
export function useInputDetection() {
|
||||
const [inputState, setInputState] = useState<InputState>(initialInputState);
|
||||
const [anyKeyPressed, setAnyKeyPressed] = useState<boolean>(false);
|
||||
const [gamepadIndex, setGamepadIndex] = useState<number | null>(null);
|
||||
|
||||
const inputDevice = useAppStore(state => state.settings.input);
|
||||
const setInputDevice = useAppStore(state => state.setInputDevice);
|
||||
|
||||
// Handle keyboard input
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (inputDevice !== 'keyboard' && inputDevice !== 'touch') {
|
||||
setInputDevice('keyboard');
|
||||
}
|
||||
|
||||
setAnyKeyPressed(true);
|
||||
|
||||
const newState = { ...inputState };
|
||||
|
||||
// WASD/Arrow keys navigation
|
||||
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') {
|
||||
newState.up = true;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') {
|
||||
newState.down = true;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
|
||||
newState.left = true;
|
||||
}
|
||||
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
|
||||
newState.right = true;
|
||||
}
|
||||
|
||||
// Action keys
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
newState.action = true;
|
||||
}
|
||||
|
||||
// Back/cancel
|
||||
if (e.key === 'Escape' || e.key === 'Backspace') {
|
||||
newState.back = true;
|
||||
}
|
||||
|
||||
// Start/pause
|
||||
if (e.key === 'p' || e.key === 'P') {
|
||||
newState.start = true;
|
||||
}
|
||||
|
||||
setInputState(newState);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
const newState = { ...inputState };
|
||||
|
||||
if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') {
|
||||
newState.up = false;
|
||||
}
|
||||
if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') {
|
||||
newState.down = false;
|
||||
}
|
||||
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
|
||||
newState.left = false;
|
||||
}
|
||||
if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
|
||||
newState.right = false;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
newState.action = false;
|
||||
}
|
||||
if (e.key === 'Escape' || e.key === 'Backspace') {
|
||||
newState.back = false;
|
||||
}
|
||||
if (e.key === 'p' || e.key === 'P') {
|
||||
newState.start = false;
|
||||
}
|
||||
|
||||
setInputState(newState);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [inputState, inputDevice, setInputDevice]);
|
||||
|
||||
// Handle gamepad input
|
||||
useEffect(() => {
|
||||
const handleGamepadConnected = (e: GamepadEvent) => {
|
||||
console.log('Gamepad connected:', e.gamepad);
|
||||
setGamepadIndex(e.gamepad.index);
|
||||
setInputDevice('gamepad');
|
||||
};
|
||||
|
||||
const handleGamepadDisconnected = (e: GamepadEvent) => {
|
||||
console.log('Gamepad disconnected:', e.gamepad);
|
||||
setGamepadIndex(null);
|
||||
if (inputDevice === 'gamepad') {
|
||||
setInputDevice('keyboard');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
|
||||
const gamepadPollingInterval = setInterval(() => {
|
||||
if (gamepadIndex !== null) {
|
||||
const gamepads = navigator.getGamepads();
|
||||
const gamepad = gamepads[gamepadIndex];
|
||||
|
||||
if (gamepad) {
|
||||
setInputDevice('gamepad');
|
||||
|
||||
const newState = { ...inputState };
|
||||
|
||||
// D-pad or left stick
|
||||
newState.up = gamepad.buttons[12]?.pressed || gamepad.axes[1] < -0.5;
|
||||
newState.down = gamepad.buttons[13]?.pressed || gamepad.axes[1] > 0.5;
|
||||
newState.left = gamepad.buttons[14]?.pressed || gamepad.axes[0] < -0.5;
|
||||
newState.right = gamepad.buttons[15]?.pressed || gamepad.axes[0] > 0.5;
|
||||
|
||||
// Action buttons
|
||||
newState.action = gamepad.buttons[0]?.pressed || gamepad.buttons[1]?.pressed;
|
||||
|
||||
// Back button
|
||||
newState.back = gamepad.buttons[1]?.pressed || gamepad.buttons[8]?.pressed;
|
||||
|
||||
// Start button
|
||||
newState.start = gamepad.buttons[9]?.pressed;
|
||||
|
||||
setInputState(newState);
|
||||
|
||||
// Check if any button is pressed
|
||||
const anyPressed = gamepad.buttons.some(button => button.pressed) ||
|
||||
Math.abs(gamepad.axes[0]) > 0.5 ||
|
||||
Math.abs(gamepad.axes[1]) > 0.5;
|
||||
|
||||
setAnyKeyPressed(anyPressed);
|
||||
}
|
||||
}
|
||||
}, 16); // ~60fps
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
clearInterval(gamepadPollingInterval);
|
||||
};
|
||||
}, [gamepadIndex, inputState, inputDevice, setInputDevice]);
|
||||
|
||||
// Reset state function
|
||||
const resetInputState = () => {
|
||||
setInputState(initialInputState);
|
||||
setAnyKeyPressed(false);
|
||||
};
|
||||
|
||||
return {
|
||||
inputState,
|
||||
anyKeyPressed,
|
||||
resetInputState,
|
||||
inputDevice,
|
||||
};
|
||||
}
|
||||
|
||||
export default useInputDetection;
|
||||
77
jd-clone/src/hooks/usePoseDetection.ts
Normal file
77
jd-clone/src/hooks/usePoseDetection.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PoseData } from '../types';
|
||||
import poseService from '../services/pose-service';
|
||||
import useAppStore from '../store/app-store';
|
||||
|
||||
export interface UsePoseDetectionOptions {
|
||||
autoConnect?: boolean;
|
||||
}
|
||||
|
||||
export function usePoseDetection(options: UsePoseDetectionOptions = {}) {
|
||||
const { autoConnect = true } = options;
|
||||
const [poseData, setPoseData] = useState<PoseData | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const setApiConnected = useAppStore(state => state.setApiConnected);
|
||||
const isApiConnected = useAppStore(state => state.isApiConnected);
|
||||
|
||||
// Connect to the pose detection API
|
||||
const connect = async () => {
|
||||
if (poseService.isConnectedToApi()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await poseService.connect();
|
||||
setApiConnected(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to connect to the pose API'));
|
||||
setApiConnected(false);
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnect from the pose detection API
|
||||
const disconnect = () => {
|
||||
poseService.disconnect();
|
||||
setApiConnected(false);
|
||||
setPoseData(null);
|
||||
};
|
||||
|
||||
// Auto-connect on component mount if requested
|
||||
useEffect(() => {
|
||||
if (autoConnect) {
|
||||
connect();
|
||||
}
|
||||
|
||||
// Subscribe to pose data updates
|
||||
const unsubscribe = poseService.subscribe('pose-data', (data) => {
|
||||
setPoseData(data);
|
||||
});
|
||||
|
||||
// Cleanup on component unmount
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (autoConnect) {
|
||||
poseService.disconnect();
|
||||
setApiConnected(false);
|
||||
}
|
||||
};
|
||||
}, [autoConnect]);
|
||||
|
||||
return {
|
||||
poseData,
|
||||
isConnecting,
|
||||
isConnected: isApiConnected,
|
||||
error,
|
||||
connect,
|
||||
disconnect
|
||||
};
|
||||
}
|
||||
|
||||
export default usePoseDetection;
|
||||
9
jd-clone/src/main.tsx
Normal file
9
jd-clone/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
266
jd-clone/src/pages/gameplay/GameplayPage.scss
Normal file
266
jd-clone/src/pages/gameplay/GameplayPage.scss
Normal file
@@ -0,0 +1,266 @@
|
||||
@import '../../assets/styles/variables.scss';
|
||||
|
||||
.gameplay-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: #121212;
|
||||
color: white;
|
||||
|
||||
// TV mode optimizations
|
||||
&--tv-mode {
|
||||
font-size: 1.2rem; // Larger text for TV viewing
|
||||
padding: 2rem;
|
||||
|
||||
// Larger margins and spacing for TV
|
||||
.gameplay-page__header,
|
||||
.gameplay-page__content,
|
||||
.gameplay-page__move-indicators {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
// Larger buttons for TV navigation
|
||||
.button {
|
||||
font-size: 1.2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading,
|
||||
&__countdown,
|
||||
&__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-size: 8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
&__score-container {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
&__score {
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__combo,
|
||||
&__multiplier {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__feedback {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
&-text {
|
||||
font-size: 5rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.3s ease-out;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
animation: feedback-pulse 0.5s ease-out;
|
||||
|
||||
&--perfect {
|
||||
color: #ffca3a;
|
||||
}
|
||||
|
||||
&--good {
|
||||
color: #8ac926;
|
||||
}
|
||||
|
||||
&--okay {
|
||||
color: #1982c4;
|
||||
}
|
||||
|
||||
&--miss {
|
||||
color: #ff595e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
.button, .button-with-controller {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__controller-hints {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__video-container {
|
||||
flex: 3;
|
||||
background-color: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__video-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__camera-container {
|
||||
flex: 1;
|
||||
min-width: 320px;
|
||||
background-color: #222;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__camera-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #ff595e;
|
||||
}
|
||||
|
||||
&__move-indicators {
|
||||
height: 20vh;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__move-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Pause overlay
|
||||
&__pause-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&__pause-menu {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||
|
||||
h2 {
|
||||
font-size: 3rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__pause-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.button-with-controller {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__controller-help {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes feedback-pulse {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
312
jd-clone/src/pages/gameplay/GameplayPage.tsx
Normal file
312
jd-clone/src/pages/gameplay/GameplayPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/common/Button';
|
||||
import { PoseRenderer } from '../../components/game/PoseRenderer';
|
||||
import usePoseDetection from '../../hooks/usePoseDetection';
|
||||
import useInputDetection from '../../hooks/useInputDetection';
|
||||
import useControllerDetection from '../../hooks/useControllerDetection';
|
||||
import useAppStore from '../../store/app-store';
|
||||
import songService from '../../services/song-service';
|
||||
import { Choreography, Song } from '../../types';
|
||||
import './GameplayPage.scss';
|
||||
import ButtonWithController from '../../components/common/ButtonWithController';
|
||||
import ControllerButton from '../../components/common/ControllerButton';
|
||||
|
||||
function GameplayPage() {
|
||||
const navigate = useNavigate();
|
||||
const [song, setSong] = useState<Song | null>(null);
|
||||
const [choreography, setChoreography] = useState<Choreography | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gameStarted, setGameStarted] = useState(false);
|
||||
const [countdown, setCountdown] = useState(3);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get app state
|
||||
const selectedSongId = useAppStore(state => state.selectedSongId);
|
||||
const difficulty = useAppStore(state => state.settings.difficulty);
|
||||
const isPlaying = useAppStore(state => state.isPlaying);
|
||||
const isPaused = useAppStore(state => state.isPaused);
|
||||
const currentScore = useAppStore(state => state.currentScore);
|
||||
const combo = useAppStore(state => state.combo);
|
||||
const multiplier = useAppStore(state => state.multiplier);
|
||||
const feedback = useAppStore(state => state.feedback);
|
||||
|
||||
// Get app actions
|
||||
const startGame = useAppStore(state => state.startGame);
|
||||
const pauseGame = useAppStore(state => state.pauseGame);
|
||||
const resumeGame = useAppStore(state => state.resumeGame);
|
||||
const endGame = useAppStore(state => state.endGame);
|
||||
const resetGameState = useAppStore(state => state.resetGameState);
|
||||
|
||||
// Connect to pose API
|
||||
const { poseData, isConnected } = usePoseDetection();
|
||||
|
||||
// Get input device data
|
||||
const { inputState, inputDevice } = useInputDetection();
|
||||
|
||||
// Get controller information
|
||||
const { isConnected: isControllerConnected, controllerType } = useControllerDetection();
|
||||
|
||||
// Handle controller inputs
|
||||
useEffect(() => {
|
||||
if (!gameStarted || loading) return;
|
||||
|
||||
// Pause/Resume with start button
|
||||
if (inputState.start) {
|
||||
handleTogglePause();
|
||||
}
|
||||
|
||||
// Exit with back button
|
||||
if (inputState.back) {
|
||||
handleExitGame();
|
||||
}
|
||||
}, [inputState, gameStarted, loading, isPaused]);
|
||||
|
||||
// Load song and choreography
|
||||
useEffect(() => {
|
||||
const loadGameData = async () => {
|
||||
if (!selectedSongId) {
|
||||
navigate('/setup');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Load song data
|
||||
const songData = await songService.getSongById(selectedSongId);
|
||||
if (!songData) {
|
||||
throw new Error(`Song with ID ${selectedSongId} not found`);
|
||||
}
|
||||
setSong(songData);
|
||||
|
||||
// Load choreography
|
||||
const choreographyData = await songService.getChoreography(selectedSongId, difficulty);
|
||||
if (!choreographyData) {
|
||||
throw new Error(`Choreography for song ${selectedSongId} at difficulty ${difficulty} not found`);
|
||||
}
|
||||
setChoreography(choreographyData);
|
||||
|
||||
// Start countdown
|
||||
setLoading(false);
|
||||
startCountdown();
|
||||
} catch (err) {
|
||||
console.error('Failed to load game data:', err);
|
||||
// Handle the error and set the error message
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load game data';
|
||||
setError(errorMessage);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset game state
|
||||
resetGameState();
|
||||
|
||||
loadGameData();
|
||||
|
||||
// Clean up when leaving the page
|
||||
return () => {
|
||||
endGame();
|
||||
};
|
||||
}, [selectedSongId, difficulty, navigate, resetGameState, endGame]);
|
||||
|
||||
// Handle countdown
|
||||
const startCountdown = () => {
|
||||
setCountdown(3);
|
||||
|
||||
const countdownInterval = setInterval(() => {
|
||||
setCountdown(prevCount => {
|
||||
if (prevCount <= 1) {
|
||||
clearInterval(countdownInterval);
|
||||
setGameStarted(true);
|
||||
startGame();
|
||||
return 0;
|
||||
}
|
||||
return prevCount - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Handle pause/resume
|
||||
const handleTogglePause = () => {
|
||||
if (isPaused) {
|
||||
resumeGame();
|
||||
} else {
|
||||
pauseGame();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle exit game
|
||||
const handleExitGame = () => {
|
||||
endGame();
|
||||
navigate('/results');
|
||||
};
|
||||
|
||||
// If we don't have a selected song, redirect to setup
|
||||
if (!selectedSongId) {
|
||||
useEffect(() => {
|
||||
navigate('/setup');
|
||||
}, [navigate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to render the pause overlay
|
||||
const renderPauseOverlay = () => {
|
||||
if (!isPaused) return null;
|
||||
|
||||
return (
|
||||
<div className="gameplay-page__pause-overlay">
|
||||
<div className="gameplay-page__pause-menu">
|
||||
<h2>Game Paused</h2>
|
||||
|
||||
<div className="gameplay-page__pause-buttons">
|
||||
<ButtonWithController
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleTogglePause}
|
||||
controllerButton="A"
|
||||
>
|
||||
Resume
|
||||
</ButtonWithController>
|
||||
|
||||
<ButtonWithController
|
||||
variant="outline"
|
||||
size="large"
|
||||
onClick={handleExitGame}
|
||||
controllerButton="B"
|
||||
>
|
||||
Exit to Results
|
||||
</ButtonWithController>
|
||||
</div>
|
||||
|
||||
<div className="gameplay-page__controller-help">
|
||||
{isControllerConnected && (
|
||||
<div className="gameplay-page__controller-hints">
|
||||
<ControllerButton button="start" label="Pause/Resume" />
|
||||
<ControllerButton button="back" label="Exit Game" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gameplay-page gameplay-page--tv-mode">
|
||||
{loading ? (
|
||||
<div className="gameplay-page__loading">
|
||||
<p>Loading game data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="gameplay-page__error">
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
<ButtonWithController
|
||||
variant="primary"
|
||||
onClick={() => navigate('/setup')}
|
||||
controllerButton="A"
|
||||
>
|
||||
Back to Song Selection
|
||||
</ButtonWithController>
|
||||
</div>
|
||||
) : !gameStarted ? (
|
||||
<div className="gameplay-page__countdown">
|
||||
<h1>{countdown}</h1>
|
||||
<p>Get ready to dance!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="gameplay-page__header">
|
||||
<div className="gameplay-page__score-container">
|
||||
<div className="gameplay-page__score">
|
||||
<h2>Score</h2>
|
||||
<p>{currentScore.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="gameplay-page__combo">
|
||||
<h3>Combo</h3>
|
||||
<p>{combo}x</p>
|
||||
</div>
|
||||
<div className="gameplay-page__multiplier">
|
||||
<h3>Multiplier</h3>
|
||||
<p>x{multiplier}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gameplay-page__feedback">
|
||||
{feedback && (
|
||||
<div className={`gameplay-page__feedback-text gameplay-page__feedback-text--${feedback}`}>
|
||||
{feedback.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="gameplay-page__controls">
|
||||
{isControllerConnected ? (
|
||||
<div className="gameplay-page__controller-hints">
|
||||
<ControllerButton button="start" label="Pause" />
|
||||
<ControllerButton button="back" label="Exit" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ButtonWithController
|
||||
variant="outline"
|
||||
onClick={handleTogglePause}
|
||||
controllerButton="start"
|
||||
>
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</ButtonWithController>
|
||||
|
||||
<ButtonWithController
|
||||
variant="text"
|
||||
onClick={handleExitGame}
|
||||
controllerButton="back"
|
||||
>
|
||||
Exit
|
||||
</ButtonWithController>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gameplay-page__content">
|
||||
<div className="gameplay-page__video-container">
|
||||
{/* Video will go here */}
|
||||
<div className="gameplay-page__video-placeholder">
|
||||
<p>Dance video will play here</p>
|
||||
<p>Song: {song?.title} by {song?.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gameplay-page__camera-container">
|
||||
{isConnected && poseData ? (
|
||||
<PoseRenderer
|
||||
poseData={poseData}
|
||||
width={320}
|
||||
height={240}
|
||||
/>
|
||||
) : (
|
||||
<div className="gameplay-page__camera-error">
|
||||
Camera not connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gameplay-page__move-indicators">
|
||||
{/* Move indicators will be rendered here */}
|
||||
<div className="gameplay-page__move-placeholder">
|
||||
<p>Move indicators will appear here</p>
|
||||
<p>Difficulty: {difficulty}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderPauseOverlay()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameplayPage;
|
||||
67
jd-clone/src/pages/home/HomePage.scss
Normal file
67
jd-clone/src/pages/home/HomePage.scss
Normal file
@@ -0,0 +1,67 @@
|
||||
@use 'sass:color';
|
||||
@use '../../assets/styles/variables.scss' as *;
|
||||
|
||||
.home-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: $background-dark;
|
||||
background-image: linear-gradient(135deg, $background-dark 0%, color.adjust($primary, $lightness: -30%) 100%);
|
||||
padding: $spacing-md;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__logo-container {
|
||||
margin-bottom: $spacing-xl * 2;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
font-size: 4rem;
|
||||
font-weight: 800;
|
||||
color: $text-light;
|
||||
text-shadow: 0 0 20px rgba($primary, 0.8), 0 0 30px rgba($secondary, 0.6);
|
||||
letter-spacing: 2px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 100%;
|
||||
transition: transform $transition-normal, box-shadow $transition-normal;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Particles animation
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(5deg); }
|
||||
100% { transform: translateY(0px) rotate(0deg); }
|
||||
}
|
||||
47
jd-clone/src/pages/home/HomePage.tsx
Normal file
47
jd-clone/src/pages/home/HomePage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/common/Button';
|
||||
import './HomePage.scss';
|
||||
|
||||
function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handlePlayClick = () => {
|
||||
navigate('/setup');
|
||||
};
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
navigate('/settings');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="home-page__content">
|
||||
<div className="home-page__logo-container">
|
||||
<h1 className="home-page__logo">JUST DANCE CLONE</h1>
|
||||
</div>
|
||||
|
||||
<div className="home-page__actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handlePlayClick}
|
||||
className="home-page__button"
|
||||
>
|
||||
PLAY NOW
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSettingsClick}
|
||||
className="home-page__button"
|
||||
>
|
||||
SETTINGS
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
121
jd-clone/src/pages/results/ResultsPage.scss
Normal file
121
jd-clone/src/pages/results/ResultsPage.scss
Normal file
@@ -0,0 +1,121 @@
|
||||
@import '../../assets/styles/variables.scss';
|
||||
|
||||
.results-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: $background-dark;
|
||||
background-image: linear-gradient(135deg, $background-dark 0%, darken($primary, 30%) 100%);
|
||||
padding: $spacing-md;
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
background-color: rgba($background-light, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: $spacing-xl;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
color: $text-light;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
&__song-info {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.25rem;
|
||||
margin: $spacing-xs 0 0;
|
||||
color: rgba($text-light, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
&__score-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
&__score-label {
|
||||
font-size: 1.25rem;
|
||||
color: $secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__score-value {
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: $text-light;
|
||||
margin: $spacing-sm 0;
|
||||
}
|
||||
|
||||
&__stars {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
&__star {
|
||||
font-size: 2.5rem;
|
||||
color: rgba($text-light, 0.3);
|
||||
|
||||
&--active {
|
||||
color: $accent;
|
||||
text-shadow: 0 0 10px rgba($accent, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
margin-bottom: $spacing-xl;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
color: $text-light;
|
||||
margin: 0 0 $spacing-xs;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: $secondary;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
&__button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
98
jd-clone/src/pages/results/ResultsPage.tsx
Normal file
98
jd-clone/src/pages/results/ResultsPage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/common/Button';
|
||||
import useAppStore from '../../store/app-store';
|
||||
import './ResultsPage.scss';
|
||||
|
||||
function ResultsPage() {
|
||||
const navigate = useNavigate();
|
||||
const currentScore = useAppStore(state => state.currentScore);
|
||||
const accuracy = useAppStore(state => state.accuracy);
|
||||
const selectedSongId = useAppStore(state => state.selectedSongId);
|
||||
const songs = useAppStore(state => state.songs);
|
||||
|
||||
// Find the selected song
|
||||
const selectedSong = songs.find(song => song.id === selectedSongId);
|
||||
|
||||
// If no score or song, redirect to home
|
||||
useEffect(() => {
|
||||
if (!selectedSongId || currentScore === 0) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [selectedSongId, currentScore, navigate]);
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
navigate('/setup');
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Calculate star rating based on score (placeholder logic)
|
||||
const calculateStars = () => {
|
||||
if (currentScore >= 9000) return 5;
|
||||
if (currentScore >= 7000) return 4;
|
||||
if (currentScore >= 5000) return 3;
|
||||
if (currentScore >= 3000) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
const stars = calculateStars();
|
||||
|
||||
return (
|
||||
<div className="results-page">
|
||||
<div className="results-page__content">
|
||||
<h1 className="results-page__title">Results</h1>
|
||||
|
||||
<div className="results-page__song-info">
|
||||
<h2>{selectedSong?.title || 'Unknown Song'}</h2>
|
||||
<p>{selectedSong?.artist || 'Unknown Artist'}</p>
|
||||
</div>
|
||||
|
||||
<div className="results-page__score-container">
|
||||
<h2 className="results-page__score-label">FINAL SCORE</h2>
|
||||
<p className="results-page__score-value">{currentScore}</p>
|
||||
|
||||
<div className="results-page__stars">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`results-page__star ${index < stars ? 'results-page__star--active' : ''}`}
|
||||
>
|
||||
★
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="results-page__stats">
|
||||
<div className="results-page__stat">
|
||||
<h3>Accuracy</h3>
|
||||
<p>{Math.round(accuracy * 100)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="results-page__actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePlayAgain}
|
||||
className="results-page__button"
|
||||
>
|
||||
PLAY AGAIN
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoHome}
|
||||
className="results-page__button"
|
||||
>
|
||||
MAIN MENU
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResultsPage;
|
||||
208
jd-clone/src/pages/settings/SettingsPage.scss
Normal file
208
jd-clone/src/pages/settings/SettingsPage.scss
Normal file
@@ -0,0 +1,208 @@
|
||||
@import '../../assets/styles/variables.scss';
|
||||
|
||||
.settings-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: $background-dark;
|
||||
padding: $spacing-md;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-xl;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: $text-light;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xl;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
&__section {
|
||||
background-color: rgba($background-light, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: $spacing-lg;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
color: $text-light;
|
||||
margin-top: 0;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
&--split {
|
||||
display: flex;
|
||||
gap: $spacing-xl;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__calibration, &__camera {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
|
||||
label {
|
||||
width: 150px;
|
||||
color: $text-light;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
margin: 0 $spacing-md;
|
||||
}
|
||||
|
||||
span {
|
||||
width: 60px;
|
||||
text-align: right;
|
||||
color: $secondary;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&--checkbox {
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__slider {
|
||||
-webkit-appearance: none;
|
||||
height: 8px;
|
||||
background: rgba($background-dark, 0.6);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: $primary;
|
||||
cursor: pointer;
|
||||
border: 2px solid $text-light;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: $primary;
|
||||
cursor: pointer;
|
||||
border: 2px solid $text-light;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__device-buttons, &__difficulty-buttons {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__device-button {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: 8px;
|
||||
background-color: $background-light;
|
||||
color: $text-light;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
flex: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten($background-light, 5%);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: $secondary;
|
||||
color: $text-dark;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__difficulty-button {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
border: none;
|
||||
flex: 1;
|
||||
|
||||
&--easy {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--hard {
|
||||
background-color: #FF9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--extreme {
|
||||
background-color: #F44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--active {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__camera-preview {
|
||||
background-color: $background-dark;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4/3;
|
||||
margin-bottom: $spacing-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__camera-placeholder {
|
||||
color: $text-light;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
246
jd-clone/src/pages/settings/SettingsPage.tsx
Normal file
246
jd-clone/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/common/Button';
|
||||
import useAppStore from '../../store/app-store';
|
||||
import usePoseDetection from '../../hooks/usePoseDetection';
|
||||
import { PoseRenderer } from '../../components/game/PoseRenderer';
|
||||
import { DifficultyLevel, InputDevice } from '../../types';
|
||||
import './SettingsPage.scss';
|
||||
|
||||
function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const settings = useAppStore(state => state.settings);
|
||||
const updateSettings = useAppStore(state => state.updateSettings);
|
||||
const updateVolume = useAppStore(state => state.updateVolume);
|
||||
const setInputDevice = useAppStore(state => state.setInputDevice);
|
||||
const setDifficulty = useAppStore(state => state.setDifficulty);
|
||||
|
||||
// State for calibration values
|
||||
const [audioDelay, setAudioDelay] = useState(settings.calibration.audioDelay);
|
||||
const [videoDelay, setVideoDelay] = useState(settings.calibration.videoDelay);
|
||||
|
||||
// Connect to pose API for camera preview
|
||||
const { poseData, isConnected, connect } = usePoseDetection();
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
// Save calibration settings
|
||||
updateSettings({
|
||||
calibration: {
|
||||
audioDelay,
|
||||
videoDelay
|
||||
}
|
||||
});
|
||||
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleVolumeChange = (type: 'master' | 'music' | 'sfx', value: number) => {
|
||||
updateVolume(type, value);
|
||||
};
|
||||
|
||||
const handleInputDeviceChange = (device: InputDevice) => {
|
||||
setInputDevice(device);
|
||||
};
|
||||
|
||||
const handleDifficultyChange = (difficulty: DifficultyLevel) => {
|
||||
setDifficulty(difficulty);
|
||||
};
|
||||
|
||||
const handleShowWebcamChange = (show: boolean) => {
|
||||
updateSettings({ showWebcam: show });
|
||||
};
|
||||
|
||||
const handleConnectCamera = () => {
|
||||
connect();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="settings-page__header">
|
||||
<Button variant="text" onClick={handleBack}>← Back to Menu</Button>
|
||||
<h1 className="settings-page__title">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__content">
|
||||
<div className="settings-page__section">
|
||||
<h2>Volume</h2>
|
||||
|
||||
<div className="settings-page__setting">
|
||||
<label htmlFor="master-volume">Master Volume</label>
|
||||
<input
|
||||
id="master-volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={settings.volume.master}
|
||||
onChange={(e) => handleVolumeChange('master', parseFloat(e.target.value))}
|
||||
className="settings-page__slider"
|
||||
/>
|
||||
<span>{Math.round(settings.volume.master * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__setting">
|
||||
<label htmlFor="music-volume">Music Volume</label>
|
||||
<input
|
||||
id="music-volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={settings.volume.music}
|
||||
onChange={(e) => handleVolumeChange('music', parseFloat(e.target.value))}
|
||||
className="settings-page__slider"
|
||||
/>
|
||||
<span>{Math.round(settings.volume.music * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__setting">
|
||||
<label htmlFor="sfx-volume">Sound Effects</label>
|
||||
<input
|
||||
id="sfx-volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={settings.volume.sfx}
|
||||
onChange={(e) => handleVolumeChange('sfx', parseFloat(e.target.value))}
|
||||
className="settings-page__slider"
|
||||
/>
|
||||
<span>{Math.round(settings.volume.sfx * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__section">
|
||||
<h2>Input Device</h2>
|
||||
|
||||
<div className="settings-page__device-buttons">
|
||||
<button
|
||||
className={`settings-page__device-button ${settings.input === 'keyboard' ? 'settings-page__device-button--active' : ''}`}
|
||||
onClick={() => handleInputDeviceChange('keyboard')}
|
||||
>
|
||||
Keyboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-page__device-button ${settings.input === 'gamepad' ? 'settings-page__device-button--active' : ''}`}
|
||||
onClick={() => handleInputDeviceChange('gamepad')}
|
||||
>
|
||||
Gamepad
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`settings-page__device-button ${settings.input === 'touch' ? 'settings-page__device-button--active' : ''}`}
|
||||
onClick={() => handleInputDeviceChange('touch')}
|
||||
>
|
||||
Touch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__section">
|
||||
<h2>Default Difficulty</h2>
|
||||
|
||||
<div className="settings-page__difficulty-buttons">
|
||||
{(['easy', 'medium', 'hard', 'extreme'] as DifficultyLevel[]).map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
className={`settings-page__difficulty-button settings-page__difficulty-button--${diff} ${settings.difficulty === diff ? 'settings-page__difficulty-button--active' : ''}`}
|
||||
onClick={() => handleDifficultyChange(diff)}
|
||||
>
|
||||
{diff.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__section settings-page__section--split">
|
||||
<div className="settings-page__calibration">
|
||||
<h2>Calibration</h2>
|
||||
|
||||
<div className="settings-page__setting">
|
||||
<label htmlFor="audio-delay">Audio Delay (ms)</label>
|
||||
<input
|
||||
id="audio-delay"
|
||||
type="range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="10"
|
||||
value={audioDelay}
|
||||
onChange={(e) => setAudioDelay(parseInt(e.target.value))}
|
||||
className="settings-page__slider"
|
||||
/>
|
||||
<span>{audioDelay} ms</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__setting">
|
||||
<label htmlFor="video-delay">Video Delay (ms)</label>
|
||||
<input
|
||||
id="video-delay"
|
||||
type="range"
|
||||
min="-500"
|
||||
max="500"
|
||||
step="10"
|
||||
value={videoDelay}
|
||||
onChange={(e) => setVideoDelay(parseInt(e.target.value))}
|
||||
className="settings-page__slider"
|
||||
/>
|
||||
<span>{videoDelay} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__camera">
|
||||
<h2>Camera</h2>
|
||||
|
||||
<div className="settings-page__camera-preview">
|
||||
{isConnected && poseData ? (
|
||||
<PoseRenderer
|
||||
poseData={poseData}
|
||||
width={320}
|
||||
height={240}
|
||||
/>
|
||||
) : (
|
||||
<div className="settings-page__camera-placeholder">
|
||||
<p>Camera not connected</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleConnectCamera}
|
||||
>
|
||||
Connect Camera
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="settings-page__setting settings-page__setting--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showWebcam}
|
||||
onChange={(e) => handleShowWebcamChange(e.target.checked)}
|
||||
/>
|
||||
Show webcam during gameplay
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-page__actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveSettings}
|
||||
>
|
||||
SAVE SETTINGS
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
229
jd-clone/src/pages/setup/GameSetupPage.scss
Normal file
229
jd-clone/src/pages/setup/GameSetupPage.scss
Normal file
@@ -0,0 +1,229 @@
|
||||
@use 'sass:color';
|
||||
@use '../../assets/styles/variables.scss' as *;
|
||||
|
||||
.setup-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: $background-dark;
|
||||
padding: $spacing-md;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-xl;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: $text-light;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: $spacing-lg;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: $breakpoint-lg) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__camera-preview {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
|
||||
@media (max-width: $breakpoint-lg) {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__camera-container {
|
||||
background-color: $background-light;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
aspect-ratio: 4/3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&__loading,
|
||||
&__error,
|
||||
&__no-pose {
|
||||
padding: $spacing-md;
|
||||
text-align: center;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
&__song-selection {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
|
||||
@media (max-width: $breakpoint-lg) {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__song-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__song-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: $background-light;
|
||||
border-radius: 8px;
|
||||
padding: $spacing-sm;
|
||||
cursor: pointer;
|
||||
transition: background-color $transition-normal;
|
||||
|
||||
&:hover {
|
||||
background-color: color.adjust($background-light, $lightness: 5%);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border: 2px solid $primary;
|
||||
background-color: rgba($primary, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__song-cover {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: $background-dark;
|
||||
margin-right: $spacing-md;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__song-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__song-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
&__song-artist {
|
||||
font-size: 0.875rem;
|
||||
color: color.adjust($text-light, $lightness: -30%);
|
||||
margin: $spacing-xs 0;
|
||||
}
|
||||
|
||||
&__song-duration {
|
||||
font-size: 0.75rem;
|
||||
color: color.adjust($text-light, $lightness: -50%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__difficulty-selection {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
|
||||
@media (max-width: $breakpoint-lg) {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
|
||||
&__difficulty-buttons {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__difficulty-button {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all $transition-normal;
|
||||
border: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--easy {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--hard {
|
||||
background-color: #FF9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--extreme {
|
||||
background-color: #F44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: $spacing-xl;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__warning {
|
||||
color: $primary;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
153
jd-clone/src/pages/setup/GameSetupPage.tsx
Normal file
153
jd-clone/src/pages/setup/GameSetupPage.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/common/Button';
|
||||
import { PoseRenderer } from '../../components/game/PoseRenderer';
|
||||
import usePoseDetection from '../../hooks/usePoseDetection';
|
||||
import useAppStore from '../../store/app-store';
|
||||
import songService from '../../services/song-service';
|
||||
import { DifficultyLevel, Song } from '../../types';
|
||||
import './GameSetupPage.scss';
|
||||
|
||||
function GameSetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSongId, setSelectedSongId] = useState<string | null>(null);
|
||||
|
||||
const difficulty = useAppStore(state => state.settings.difficulty);
|
||||
const setDifficulty = useAppStore(state => state.setDifficulty);
|
||||
const selectSong = useAppStore(state => state.selectSong);
|
||||
|
||||
// Connect to pose API
|
||||
const { poseData, isConnected, isConnecting, error } = usePoseDetection();
|
||||
|
||||
// Load songs
|
||||
useEffect(() => {
|
||||
const loadSongs = async () => {
|
||||
try {
|
||||
const songList = await songService.getSongs();
|
||||
setSongs(songList);
|
||||
|
||||
// Select the first song by default
|
||||
if (songList.length > 0 && !selectedSongId) {
|
||||
setSelectedSongId(songList[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load songs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSongs();
|
||||
}, [selectedSongId]);
|
||||
|
||||
const handleDifficultyChange = (difficulty: DifficultyLevel) => {
|
||||
setDifficulty(difficulty);
|
||||
};
|
||||
|
||||
const handleSongSelect = (songId: string) => {
|
||||
setSelectedSongId(songId);
|
||||
};
|
||||
|
||||
const handleStartGame = () => {
|
||||
if (selectedSongId) {
|
||||
selectSong(selectedSongId);
|
||||
navigate('/play');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Find the currently selected song
|
||||
const selectedSong = songs.find(song => song.id === selectedSongId);
|
||||
|
||||
return (
|
||||
<div className="setup-page">
|
||||
<div className="setup-page__header">
|
||||
<Button variant="text" onClick={handleBack}>← Back to Menu</Button>
|
||||
<h1 className="setup-page__title">Game Setup</h1>
|
||||
</div>
|
||||
|
||||
<div className="setup-page__content">
|
||||
<div className="setup-page__camera-preview">
|
||||
<h2>Camera Preview</h2>
|
||||
<div className="setup-page__camera-container">
|
||||
{isConnecting && <div className="setup-page__loading">Connecting to camera...</div>}
|
||||
{error && <div className="setup-page__error">Failed to connect to camera. Please check your permissions.</div>}
|
||||
{isConnected && (
|
||||
poseData ? (
|
||||
<PoseRenderer
|
||||
poseData={poseData}
|
||||
width={480}
|
||||
height={360}
|
||||
/>
|
||||
) : (
|
||||
<div className="setup-page__no-pose">No pose detected. Please make sure you're visible in the camera.</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setup-page__song-selection">
|
||||
<h2>Select a Song</h2>
|
||||
{loading ? (
|
||||
<div className="setup-page__loading">Loading songs...</div>
|
||||
) : (
|
||||
<div className="setup-page__song-list">
|
||||
{songs.map(song => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`setup-page__song-item ${selectedSongId === song.id ? 'setup-page__song-item--selected' : ''}`}
|
||||
onClick={() => handleSongSelect(song.id)}
|
||||
>
|
||||
<div className="setup-page__song-cover" style={{ backgroundImage: `url(${song.coverUrl})` }} />
|
||||
<div className="setup-page__song-info">
|
||||
<h3 className="setup-page__song-title">{song.title}</h3>
|
||||
<p className="setup-page__song-artist">{song.artist}</p>
|
||||
<p className="setup-page__song-duration">{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setup-page__difficulty-selection">
|
||||
<h2>Select Difficulty</h2>
|
||||
<div className="setup-page__difficulty-buttons">
|
||||
{selectedSong?.difficulty.map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
className={`setup-page__difficulty-button setup-page__difficulty-button--${diff} ${difficulty === diff ? 'setup-page__difficulty-button--selected' : ''}`}
|
||||
onClick={() => handleDifficultyChange(diff)}
|
||||
disabled={!selectedSong.difficulty.includes(diff)}
|
||||
>
|
||||
{diff.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setup-page__actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={handleStartGame}
|
||||
disabled={!selectedSongId || !isConnected}
|
||||
>
|
||||
START DANCING
|
||||
</Button>
|
||||
|
||||
{!isConnected && (
|
||||
<p className="setup-page__warning">Camera connection required to play. Please check your camera permissions.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameSetupPage;
|
||||
94
jd-clone/src/services/pose-service.ts
Normal file
94
jd-clone/src/services/pose-service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { PoseData } from '../types';
|
||||
|
||||
class PoseService {
|
||||
private socket: Socket | null = null;
|
||||
private listeners: Map<string, ((data: PoseData) => void)[]> = new Map();
|
||||
private isConnected: boolean = false;
|
||||
private lastPoseData: PoseData | null = null;
|
||||
|
||||
constructor(private apiUrl: string = 'http://localhost:5000') {}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.socket = io(this.apiUrl);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
console.log('Connected to pose detection API');
|
||||
this.isConnected = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.socket.on('landmarks', (data: string) => {
|
||||
try {
|
||||
const poseData: PoseData = JSON.parse(data);
|
||||
this.lastPoseData = poseData;
|
||||
this.notifyListeners('pose-data', poseData);
|
||||
} catch (error) {
|
||||
console.error('Error parsing pose data:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('Disconnected from pose detection API');
|
||||
this.isConnected = false;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to pose detection API:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(event: string, callback: (data: PoseData) => void): () => void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
|
||||
this.listeners.get(event)?.push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const eventListeners = this.listeners.get(event);
|
||||
if (eventListeners) {
|
||||
const index = eventListeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
eventListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private notifyListeners(event: string, data: PoseData): void {
|
||||
const eventListeners = this.listeners.get(event);
|
||||
if (eventListeners) {
|
||||
eventListeners.forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
getLastPoseData(): PoseData | null {
|
||||
return this.lastPoseData;
|
||||
}
|
||||
|
||||
isConnectedToApi(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const poseService = new PoseService();
|
||||
export default poseService;
|
||||
158
jd-clone/src/services/song-service.ts
Normal file
158
jd-clone/src/services/song-service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Song, Choreography, DifficultyLevel, Move } from '../types';
|
||||
|
||||
// Mock song data for development purposes
|
||||
const MOCK_SONGS: Song[] = [
|
||||
{
|
||||
id: 'song1',
|
||||
title: 'Dance The Night',
|
||||
artist: 'Dua Lipa',
|
||||
bpm: 120,
|
||||
duration: 176,
|
||||
coverUrl: 'https://example.com/covers/dance-the-night.jpg',
|
||||
audioUrl: 'https://example.com/songs/dance-the-night.mp3',
|
||||
videoUrl: 'https://example.com/videos/dance-the-night.mp4',
|
||||
difficulty: ['easy', 'medium', 'hard'],
|
||||
tags: ['pop', 'upbeat', 'disco']
|
||||
},
|
||||
{
|
||||
id: 'song2',
|
||||
title: 'Levitating',
|
||||
artist: 'Dua Lipa ft. DaBaby',
|
||||
bpm: 103,
|
||||
duration: 203,
|
||||
coverUrl: 'https://example.com/covers/levitating.jpg',
|
||||
audioUrl: 'https://example.com/songs/levitating.mp3',
|
||||
videoUrl: 'https://example.com/videos/levitating.mp4',
|
||||
difficulty: ['easy', 'medium', 'hard', 'extreme'],
|
||||
tags: ['pop', 'upbeat', 'disco']
|
||||
},
|
||||
{
|
||||
id: 'song3',
|
||||
title: 'Physical',
|
||||
artist: 'Dua Lipa',
|
||||
bpm: 124,
|
||||
duration: 183,
|
||||
coverUrl: 'https://example.com/covers/physical.jpg',
|
||||
audioUrl: 'https://example.com/songs/physical.mp3',
|
||||
videoUrl: 'https://example.com/videos/physical.mp4',
|
||||
difficulty: ['medium', 'hard', 'extreme'],
|
||||
tags: ['pop', 'dance', 'workout']
|
||||
}
|
||||
];
|
||||
|
||||
// Mock choreography data with placeholder moves
|
||||
const MOCK_CHOREOGRAPHIES: Record<string, Record<DifficultyLevel, Choreography>> = {
|
||||
song1: {
|
||||
easy: {
|
||||
songId: 'song1',
|
||||
difficulty: 'easy',
|
||||
moves: Array(20).fill(null).map((_, index) => ({
|
||||
id: `song1-easy-move-${index}`,
|
||||
startTime: index * 8000,
|
||||
duration: 4000,
|
||||
keyPosePoints: [], // This would contain actual pose landmarks
|
||||
difficulty: 'easy',
|
||||
score: 100
|
||||
}))
|
||||
},
|
||||
medium: {
|
||||
songId: 'song1',
|
||||
difficulty: 'medium',
|
||||
moves: Array(30).fill(null).map((_, index) => ({
|
||||
id: `song1-medium-move-${index}`,
|
||||
startTime: index * 6000,
|
||||
duration: 3000,
|
||||
keyPosePoints: [],
|
||||
difficulty: 'medium',
|
||||
score: 150
|
||||
}))
|
||||
},
|
||||
hard: {
|
||||
songId: 'song1',
|
||||
difficulty: 'hard',
|
||||
moves: Array(40).fill(null).map((_, index) => ({
|
||||
id: `song1-hard-move-${index}`,
|
||||
startTime: index * 4000,
|
||||
duration: 2000,
|
||||
keyPosePoints: [],
|
||||
difficulty: 'hard',
|
||||
score: 200
|
||||
}))
|
||||
},
|
||||
extreme: {
|
||||
songId: 'song1',
|
||||
difficulty: 'extreme',
|
||||
moves: Array(50).fill(null).map((_, index) => ({
|
||||
id: `song1-extreme-move-${index}`,
|
||||
startTime: index * 3000,
|
||||
duration: 1500,
|
||||
keyPosePoints: [],
|
||||
difficulty: 'extreme',
|
||||
score: 300
|
||||
}))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class SongService {
|
||||
/**
|
||||
* Get all available songs
|
||||
*/
|
||||
async getSongs(): Promise<Song[]> {
|
||||
// In a real app, this would fetch from an API
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(MOCK_SONGS);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific song by ID
|
||||
*/
|
||||
async getSongById(id: string): Promise<Song | null> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const song = MOCK_SONGS.find(s => s.id === id) || null;
|
||||
resolve(song);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get choreography for a song at a specific difficulty
|
||||
*/
|
||||
async getChoreography(songId: string, difficulty: DifficultyLevel): Promise<Choreography | null> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const songChoreographies = MOCK_CHOREOGRAPHIES[songId];
|
||||
if (!songChoreographies) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const choreography = songChoreographies[difficulty];
|
||||
resolve(choreography || null);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a choreography from a video (placeholder for future implementation)
|
||||
*/
|
||||
async generateChoreography(videoUrl: string, songId: string, difficulty: DifficultyLevel): Promise<Choreography | null> {
|
||||
// This would use the pose detection API to analyze a video and generate choreography data
|
||||
console.log(`Generating choreography for ${videoUrl}, song ${songId}, difficulty ${difficulty}`);
|
||||
|
||||
// For now, just return a mock choreography
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(MOCK_CHOREOGRAPHIES.song1[difficulty]);
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const songService = new SongService();
|
||||
export default songService;
|
||||
149
jd-clone/src/store/app-store.ts
Normal file
149
jd-clone/src/store/app-store.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { create } from 'zustand';
|
||||
import { DifficultyLevel, FeedbackType, GameSettings, InputDevice, Song } from '../types';
|
||||
|
||||
interface AppState {
|
||||
// Navigation
|
||||
currentPage: string;
|
||||
setCurrentPage: (page: string) => void;
|
||||
|
||||
// Songs and selection
|
||||
songs: Song[];
|
||||
selectedSongId: string | null;
|
||||
setSongs: (songs: Song[]) => void;
|
||||
selectSong: (songId: string) => void;
|
||||
|
||||
// Game settings
|
||||
settings: GameSettings;
|
||||
updateSettings: (settings: Partial<GameSettings>) => void;
|
||||
updateVolume: (type: 'master' | 'music' | 'sfx', value: number) => void;
|
||||
setInputDevice: (device: InputDevice) => void;
|
||||
setDifficulty: (difficulty: DifficultyLevel) => void;
|
||||
|
||||
// Game state
|
||||
isPlaying: boolean;
|
||||
isPaused: boolean;
|
||||
currentScore: number;
|
||||
combo: number;
|
||||
multiplier: number;
|
||||
feedback: FeedbackType;
|
||||
accuracy: number;
|
||||
|
||||
// Game control functions
|
||||
startGame: () => void;
|
||||
pauseGame: () => void;
|
||||
resumeGame: () => void;
|
||||
endGame: () => void;
|
||||
updateScore: (points: number, feedbackType: FeedbackType) => void;
|
||||
resetGameState: () => void;
|
||||
|
||||
// API connection state
|
||||
isApiConnected: boolean;
|
||||
setApiConnected: (isConnected: boolean) => void;
|
||||
}
|
||||
|
||||
// Initial game settings
|
||||
const defaultSettings: GameSettings = {
|
||||
volume: {
|
||||
master: 0.8,
|
||||
music: 1.0,
|
||||
sfx: 0.7,
|
||||
},
|
||||
input: 'keyboard',
|
||||
calibration: {
|
||||
audioDelay: 0,
|
||||
videoDelay: 0,
|
||||
},
|
||||
showWebcam: true,
|
||||
difficulty: 'medium',
|
||||
};
|
||||
|
||||
// Create the store
|
||||
const useAppStore = create<AppState>((set) => ({
|
||||
// Navigation
|
||||
currentPage: 'home',
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
|
||||
// Songs and selection
|
||||
songs: [],
|
||||
selectedSongId: null,
|
||||
setSongs: (songs) => set({ songs }),
|
||||
selectSong: (songId) => set({ selectedSongId: songId }),
|
||||
|
||||
// Game settings
|
||||
settings: defaultSettings,
|
||||
updateSettings: (newSettings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings }
|
||||
})),
|
||||
updateVolume: (type, value) =>
|
||||
set((state) => ({
|
||||
settings: {
|
||||
...state.settings,
|
||||
volume: {
|
||||
...state.settings.volume,
|
||||
[type]: value,
|
||||
}
|
||||
}
|
||||
})),
|
||||
setInputDevice: (device) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, input: device }
|
||||
})),
|
||||
setDifficulty: (difficulty) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, difficulty }
|
||||
})),
|
||||
|
||||
// Game state
|
||||
isPlaying: false,
|
||||
isPaused: false,
|
||||
currentScore: 0,
|
||||
combo: 0,
|
||||
multiplier: 1,
|
||||
feedback: null,
|
||||
accuracy: 0,
|
||||
|
||||
// Game control functions
|
||||
startGame: () => set({
|
||||
isPlaying: true,
|
||||
isPaused: false,
|
||||
currentScore: 0,
|
||||
combo: 0,
|
||||
multiplier: 1,
|
||||
feedback: null
|
||||
}),
|
||||
pauseGame: () => set({ isPaused: true }),
|
||||
resumeGame: () => set({ isPaused: false }),
|
||||
endGame: () => set({ isPlaying: false, isPaused: false }),
|
||||
updateScore: (points, feedbackType) => set((state) => {
|
||||
// Calculate new combo and multiplier based on feedback
|
||||
let newCombo = feedbackType === 'miss' ? 0 : state.combo + 1;
|
||||
let newMultiplier = 1;
|
||||
|
||||
if (newCombo >= 30) newMultiplier = 4;
|
||||
else if (newCombo >= 20) newMultiplier = 3;
|
||||
else if (newCombo >= 10) newMultiplier = 2;
|
||||
|
||||
return {
|
||||
currentScore: state.currentScore + (points * newMultiplier),
|
||||
combo: newCombo,
|
||||
multiplier: newMultiplier,
|
||||
feedback: feedbackType
|
||||
};
|
||||
}),
|
||||
resetGameState: () => set({
|
||||
currentScore: 0,
|
||||
combo: 0,
|
||||
multiplier: 1,
|
||||
feedback: null,
|
||||
accuracy: 0,
|
||||
isPlaying: false,
|
||||
isPaused: false
|
||||
}),
|
||||
|
||||
// API connection state
|
||||
isApiConnected: false,
|
||||
setApiConnected: (isConnected) => set({ isApiConnected: isConnected }),
|
||||
}));
|
||||
|
||||
export default useAppStore;
|
||||
82
jd-clone/src/types/index.ts
Normal file
82
jd-clone/src/types/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Pose landmark data from the API
|
||||
export interface PoseLandmark {
|
||||
idx: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
visibility: number;
|
||||
}
|
||||
|
||||
export interface PoseConnection {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface PoseData {
|
||||
landmarks: PoseLandmark[];
|
||||
connections: PoseConnection[];
|
||||
image_width: number;
|
||||
image_height: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Game content types
|
||||
export interface Song {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
bpm: number;
|
||||
duration: number;
|
||||
coverUrl: string;
|
||||
audioUrl: string;
|
||||
videoUrl: string;
|
||||
difficulty: DifficultyLevel[];
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type DifficultyLevel = 'easy' | 'medium' | 'hard' | 'extreme';
|
||||
|
||||
export interface Move {
|
||||
id: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
keyPosePoints: PoseLandmark[];
|
||||
difficulty: DifficultyLevel;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface Choreography {
|
||||
songId: string;
|
||||
difficulty: DifficultyLevel;
|
||||
moves: Move[];
|
||||
}
|
||||
|
||||
// Input types
|
||||
export type InputDevice = 'keyboard' | 'gamepad' | 'touch';
|
||||
|
||||
// Game state types
|
||||
export interface GameState {
|
||||
currentScore: number;
|
||||
combo: number;
|
||||
multiplier: number;
|
||||
accuracy: number;
|
||||
feedback: FeedbackType;
|
||||
}
|
||||
|
||||
export type FeedbackType = 'perfect' | 'good' | 'okay' | 'miss' | null;
|
||||
|
||||
// Settings
|
||||
export interface GameSettings {
|
||||
volume: {
|
||||
master: number;
|
||||
music: number;
|
||||
sfx: number;
|
||||
};
|
||||
input: InputDevice;
|
||||
calibration: {
|
||||
audioDelay: number;
|
||||
videoDelay: number;
|
||||
};
|
||||
showWebcam: boolean;
|
||||
difficulty: DifficultyLevel;
|
||||
}
|
||||
4
jd-clone/src/types/scss.d.ts
vendored
Normal file
4
jd-clone/src/types/scss.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.scss' {
|
||||
const content: { [className: string]: string };
|
||||
export default content;
|
||||
}
|
||||
137
jd-clone/src/utils/pose-comparison.ts
Normal file
137
jd-clone/src/utils/pose-comparison.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { FeedbackType, Move, PoseLandmark } from '../types';
|
||||
|
||||
// Landmark weights - we care more about some points than others
|
||||
const LANDMARK_WEIGHTS: Record<number, number> = {
|
||||
// Shoulders
|
||||
11: 1.5, // Left shoulder
|
||||
12: 1.5, // Right shoulder
|
||||
|
||||
// Arms
|
||||
13: 1.2, // Left elbow
|
||||
14: 1.2, // Right elbow
|
||||
15: 1.8, // Left wrist
|
||||
16: 1.8, // Right wrist
|
||||
|
||||
// Hips
|
||||
23: 1.0, // Left hip
|
||||
24: 1.0, // Right hip
|
||||
|
||||
// Legs
|
||||
25: 1.2, // Left knee
|
||||
26: 1.2, // Right knee
|
||||
27: 1.5, // Left ankle
|
||||
28: 1.5, // Right ankle
|
||||
|
||||
// Default weight for all other landmarks
|
||||
default: 0.8
|
||||
};
|
||||
|
||||
// Maximum distance in pixels for a pose to be considered a match
|
||||
const MAX_DISTANCE = 100;
|
||||
|
||||
/**
|
||||
* Calculate the similarity between two pose landmarks
|
||||
* @param userLandmarks The user's current pose landmarks
|
||||
* @param expectedLandmarks The expected pose landmarks
|
||||
* @returns A score between 0 and 1 representing similarity
|
||||
*/
|
||||
export function calculatePoseSimilarity(
|
||||
userLandmarks: PoseLandmark[],
|
||||
expectedLandmarks: PoseLandmark[]
|
||||
): number {
|
||||
// If either set of landmarks is missing, return 0
|
||||
if (!userLandmarks.length || !expectedLandmarks.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let totalWeightedDistance = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
// Loop through the landmarks
|
||||
for (let i = 0; i < Math.min(userLandmarks.length, expectedLandmarks.length); i++) {
|
||||
const userLandmark = userLandmarks[i];
|
||||
const expectedLandmark = expectedLandmarks[i];
|
||||
|
||||
// Skip landmarks with low visibility
|
||||
if (userLandmark.visibility < 0.5 || expectedLandmark.visibility < 0.5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate Euclidean distance between the 2D points
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(userLandmark.x - expectedLandmark.x, 2) +
|
||||
Math.pow(userLandmark.y - expectedLandmark.y, 2)
|
||||
);
|
||||
|
||||
// Get the weight for this landmark
|
||||
const weight = LANDMARK_WEIGHTS[i] || LANDMARK_WEIGHTS.default;
|
||||
|
||||
// Add to totals
|
||||
totalWeightedDistance += distance * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
// If no valid landmarks were found, return 0
|
||||
if (totalWeight === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate the average weighted distance
|
||||
const avgWeightedDistance = totalWeightedDistance / totalWeight;
|
||||
|
||||
// Convert to a similarity score between 0 and 1
|
||||
// Where 0 = MAX_DISTANCE or more apart, 1 = perfect match
|
||||
const similarity = Math.max(0, 1 - (avgWeightedDistance / MAX_DISTANCE));
|
||||
|
||||
return similarity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback type based on similarity score
|
||||
* @param similarity Pose similarity score (0-1)
|
||||
* @returns Feedback type
|
||||
*/
|
||||
export function getFeedbackFromSimilarity(similarity: number): FeedbackType {
|
||||
if (similarity >= 0.9) {
|
||||
return 'perfect';
|
||||
} else if (similarity >= 0.7) {
|
||||
return 'good';
|
||||
} else if (similarity >= 0.5) {
|
||||
return 'okay';
|
||||
} else {
|
||||
return 'miss';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the points earned for a move based on similarity
|
||||
* @param move The move being performed
|
||||
* @param similarity Pose similarity score (0-1)
|
||||
* @returns Points earned
|
||||
*/
|
||||
export function calculatePointsForMove(move: Move, similarity: number): number {
|
||||
const feedback = getFeedbackFromSimilarity(similarity);
|
||||
|
||||
// Base points from the move
|
||||
const basePoints = move.score;
|
||||
|
||||
// Multiplier based on feedback
|
||||
let multiplier = 0;
|
||||
switch (feedback) {
|
||||
case 'perfect':
|
||||
multiplier = 1.0;
|
||||
break;
|
||||
case 'good':
|
||||
multiplier = 0.7;
|
||||
break;
|
||||
case 'okay':
|
||||
multiplier = 0.4;
|
||||
break;
|
||||
case 'miss':
|
||||
default:
|
||||
multiplier = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return Math.round(basePoints * multiplier);
|
||||
}
|
||||
26
jd-clone/tsconfig.app.json
Normal file
26
jd-clone/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
jd-clone/tsconfig.json
Normal file
7
jd-clone/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
jd-clone/tsconfig.node.json
Normal file
24
jd-clone/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
jd-clone/vite.config.ts
Normal file
7
jd-clone/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
340
pose_detector_api.py
Normal file
340
pose_detector_api.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import base64
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from flask import Flask, Response, render_template
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# Initialize MediaPipe Pose
|
||||
mp_drawing = mp.solutions.drawing_utils
|
||||
mp_drawing_styles = mp.solutions.drawing_styles
|
||||
mp_pose = mp.solutions.pose
|
||||
|
||||
# Define colors
|
||||
BLUE = (255, 0, 0)
|
||||
GREEN = (0, 255, 0)
|
||||
RED = (0, 0, 255)
|
||||
YELLOW = (0, 255, 255)
|
||||
|
||||
# Initialize Flask app and SocketIO
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
|
||||
# Global variables to store the latest frames and data
|
||||
latest_raw_frame = None
|
||||
latest_annotated_frame = None
|
||||
latest_landmarks_data = None
|
||||
processing_active = True
|
||||
|
||||
def process_landmarks(landmarks, width, height):
|
||||
"""Process landmarks into a JSON-serializable format"""
|
||||
landmark_data = []
|
||||
|
||||
if landmarks:
|
||||
for idx, landmark in enumerate(landmarks):
|
||||
landmark_data.append({
|
||||
'idx': idx,
|
||||
'x': landmark.x * width,
|
||||
'y': landmark.y * height,
|
||||
'z': landmark.z,
|
||||
'visibility': landmark.visibility
|
||||
})
|
||||
|
||||
return landmark_data
|
||||
|
||||
def get_body_connections():
|
||||
"""Return the connections between body parts"""
|
||||
connections = []
|
||||
for connection in mp_pose.POSE_CONNECTIONS:
|
||||
connections.append({
|
||||
'start': connection[0],
|
||||
'end': connection[1]
|
||||
})
|
||||
return connections
|
||||
|
||||
def pose_detection_thread():
|
||||
global latest_raw_frame, latest_annotated_frame, latest_landmarks_data, processing_active
|
||||
|
||||
# Initialize webcam
|
||||
cap = cv2.VideoCapture(0)
|
||||
|
||||
if not cap.isOpened():
|
||||
print("Error: Could not open webcam")
|
||||
return
|
||||
|
||||
# Get webcam properties
|
||||
frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
|
||||
# Set up MediaPipe Pose
|
||||
with mp_pose.Pose(
|
||||
min_detection_confidence=0.5,
|
||||
min_tracking_confidence=0.5) as pose:
|
||||
|
||||
# Initialize the last_time variable properly
|
||||
last_time = time.time()
|
||||
|
||||
while cap.isOpened() and processing_active:
|
||||
success, frame = cap.read()
|
||||
if not success:
|
||||
print("Error: Could not read frame")
|
||||
break
|
||||
|
||||
# Flip the image horizontally for a selfie-view display
|
||||
frame = cv2.flip(frame, 1)
|
||||
|
||||
# Store a copy of the raw frame
|
||||
raw_frame = frame.copy()
|
||||
latest_raw_frame = raw_frame.copy()
|
||||
|
||||
# To improve performance, optionally mark the image as not writeable
|
||||
frame.flags.writeable = False
|
||||
# Convert image to RGB
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Process the image with MediaPipe Pose
|
||||
results = pose.process(frame_rgb)
|
||||
|
||||
# Draw the pose annotations on the image
|
||||
frame.flags.writeable = True
|
||||
|
||||
# Prepare landmark data dictionary
|
||||
landmarks_data = {
|
||||
'landmarks': [],
|
||||
'connections': get_body_connections(),
|
||||
'image_width': frame_width,
|
||||
'image_height': frame_height,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
|
||||
if results.pose_landmarks:
|
||||
# Store landmark data
|
||||
landmarks_data['landmarks'] = process_landmarks(
|
||||
results.pose_landmarks.landmark,
|
||||
frame_width,
|
||||
frame_height
|
||||
)
|
||||
|
||||
# Draw the pose landmarks
|
||||
mp_drawing.draw_landmarks(
|
||||
frame,
|
||||
results.pose_landmarks,
|
||||
mp_pose.POSE_CONNECTIONS,
|
||||
landmark_drawing_spec=mp_drawing_styles.get_default_pose_landmarks_style())
|
||||
|
||||
# You can also draw custom lines or highlight specific landmarks
|
||||
landmarks = results.pose_landmarks.landmark
|
||||
|
||||
# Example: Draw a line between the shoulders with a custom color
|
||||
h, w, c = frame.shape
|
||||
try:
|
||||
# Left shoulder to right shoulder
|
||||
left_shoulder = (int(landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.LEFT_SHOULDER].y * h))
|
||||
right_shoulder = (int(landmarks[mp_pose.PoseLandmark.RIGHT_SHOULDER].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.RIGHT_SHOULDER].y * h))
|
||||
cv2.line(frame, left_shoulder, right_shoulder, YELLOW, 4)
|
||||
|
||||
# Custom lines for torso
|
||||
left_hip = (int(landmarks[mp_pose.PoseLandmark.LEFT_HIP].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.LEFT_HIP].y * h))
|
||||
right_hip = (int(landmarks[mp_pose.PoseLandmark.RIGHT_HIP].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.RIGHT_HIP].y * h))
|
||||
|
||||
# Draw torso lines
|
||||
cv2.line(frame, left_shoulder, left_hip, RED, 4)
|
||||
cv2.line(frame, right_shoulder, right_hip, RED, 4)
|
||||
cv2.line(frame, left_hip, right_hip, RED, 4)
|
||||
|
||||
# Draw arms
|
||||
left_elbow = (int(landmarks[mp_pose.PoseLandmark.LEFT_ELBOW].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.LEFT_ELBOW].y * h))
|
||||
right_elbow = (int(landmarks[mp_pose.PoseLandmark.RIGHT_ELBOW].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.RIGHT_ELBOW].y * h))
|
||||
left_wrist = (int(landmarks[mp_pose.PoseLandmark.LEFT_WRIST].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.LEFT_WRIST].y * h))
|
||||
right_wrist = (int(landmarks[mp_pose.PoseLandmark.RIGHT_WRIST].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.RIGHT_WRIST].y * h))
|
||||
|
||||
cv2.line(frame, left_shoulder, left_elbow, BLUE, 4)
|
||||
cv2.line(frame, left_elbow, left_wrist, BLUE, 4)
|
||||
cv2.line(frame, right_shoulder, right_elbow, BLUE, 4)
|
||||
cv2.line(frame, right_elbow, right_wrist, BLUE, 4)
|
||||
|
||||
# Draw legs
|
||||
left_knee = (int(landmarks[mp_pose.PoseLandmark.LEFT_KNEE].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.LEFT_KNEE].y * h))
|
||||
right_knee = (int(landmarks[mp_pose.PoseLandmark.RIGHT_KNEE].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.RIGHT_KNEE].y * h))
|
||||
left_ankle = (int(landmarks[mp_pose.PoseLandmark.LEFT_ANKLE].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.LEFT_ANKLE].y * h))
|
||||
right_ankle = (int(landmarks[mp_pose.PoseLandmark.RIGHT_ANKLE].x * w),
|
||||
int(landmarks[mp_pose.PoseLandmark.RIGHT_ANKLE].y * h))
|
||||
|
||||
cv2.line(frame, left_hip, left_knee, GREEN, 4)
|
||||
cv2.line(frame, left_knee, left_ankle, GREEN, 4)
|
||||
cv2.line(frame, right_hip, right_knee, GREEN, 4)
|
||||
cv2.line(frame, right_knee, right_ankle, GREEN, 4)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add FPS counter
|
||||
current_time = time.time()
|
||||
time_diff = current_time - last_time
|
||||
# Avoid division by zero
|
||||
if time_diff > 0:
|
||||
fps = int(1 / time_diff)
|
||||
else:
|
||||
fps = 0
|
||||
last_time = current_time
|
||||
cv2.putText(frame, f"FPS: {fps}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
|
||||
|
||||
# Update global variables with the latest data
|
||||
latest_annotated_frame = frame.copy()
|
||||
latest_landmarks_data = landmarks_data
|
||||
|
||||
# Emit landmark data through SocketIO
|
||||
socketio.emit('landmarks', json.dumps(landmarks_data))
|
||||
|
||||
# Sleep to avoid excessive CPU usage
|
||||
time.sleep(0.01)
|
||||
|
||||
# Release the webcam
|
||||
cap.release()
|
||||
|
||||
def generate_frames(get_annotated=False):
|
||||
"""Generator function for streaming frames"""
|
||||
while True:
|
||||
if get_annotated and latest_annotated_frame is not None:
|
||||
frame = latest_annotated_frame.copy()
|
||||
elif latest_raw_frame is not None:
|
||||
frame = latest_raw_frame.copy()
|
||||
else:
|
||||
# If no frames are available yet, yield an empty response
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Encode frame as JPEG
|
||||
_, buffer = cv2.imencode('.jpg', frame)
|
||||
frame_bytes = buffer.tobytes()
|
||||
|
||||
# Yield the frame in the format expected by Response
|
||||
yield (b'--frame\r\n'
|
||||
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
|
||||
|
||||
# Flask routes
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Serve a simple HTML page for testing the API"""
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Pose Detection API</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.video-container {
|
||||
width: 480px;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
color: #333;
|
||||
}
|
||||
pre {
|
||||
background-color: #f4f4f4;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Pose Detection API</h1>
|
||||
|
||||
<div class="container">
|
||||
<div class="video-container">
|
||||
<h2>Raw Video Feed</h2>
|
||||
<img src="/video_feed" width="100%" />
|
||||
</div>
|
||||
|
||||
<div class="video-container">
|
||||
<h2>Annotated Video Feed</h2>
|
||||
<img src="/video_feed/annotated" width="100%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Landmark Data (Live Updates)</h2>
|
||||
<pre id="landmarks-data">Waiting for data...</pre>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||
<script>
|
||||
// Connect to the Socket.IO server
|
||||
const socket = io();
|
||||
|
||||
// Listen for landmark data
|
||||
socket.on('landmarks', function(data) {
|
||||
const landmarksData = JSON.parse(data);
|
||||
document.getElementById('landmarks-data').textContent =
|
||||
JSON.stringify(landmarksData, null, 2);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@app.route('/video_feed')
|
||||
def video_feed():
|
||||
"""Route to serve the raw video feed"""
|
||||
return Response(generate_frames(get_annotated=False),
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame')
|
||||
|
||||
@app.route('/video_feed/annotated')
|
||||
def video_feed_annotated():
|
||||
"""Route to serve the annotated video feed"""
|
||||
return Response(generate_frames(get_annotated=True),
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame')
|
||||
|
||||
@app.route('/landmarks')
|
||||
def get_landmarks():
|
||||
"""Route to get the latest landmarks data"""
|
||||
if latest_landmarks_data:
|
||||
return Response(json.dumps(latest_landmarks_data),
|
||||
mimetype='application/json')
|
||||
else:
|
||||
return Response(json.dumps({"error": "No landmarks data available yet"}),
|
||||
mimetype='application/json')
|
||||
|
||||
def main():
|
||||
# Start the pose detection thread
|
||||
detection_thread = threading.Thread(target=pose_detection_thread)
|
||||
detection_thread.daemon = True
|
||||
detection_thread.start()
|
||||
|
||||
# Start the Flask app with SocketIO
|
||||
print("Starting API server at http://127.0.0.1:5000")
|
||||
socketio.run(app, host='0.0.0.0', port=5000, debug=False, allow_unsafe_werkzeug=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("Shutting down...")
|
||||
processing_active = False
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
opencv-python>=4.5.0
|
||||
mediapipe>=0.8.9
|
||||
flask>=2.0.0
|
||||
flask-socketio>=5.1.0
|
||||
flask-cors>=3.0.10
|
||||
numpy>=1.19.0
|
||||
Reference in New Issue
Block a user