This commit is contained in:
2025-05-04 03:56:07 -03:00
parent ca51700154
commit 2a6d0179f0
52 changed files with 9452 additions and 1 deletions

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

37
jd-clone/package.json Normal file
View 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
View 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
View 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;

View 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%);
}

View 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%;
}

View 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;

View 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;
}
}

View 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;

View File

@@ -0,0 +1,2 @@
export { default, Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import { ButtonWithController } from './ButtonWithController';
export { ButtonWithController };
export default ButtonWithController;

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
import { ControllerButton } from './ControllerButton';
export { ControllerButton };
export type { ButtonType, ControllerButtonProps } from './ControllerButton';
export default ControllerButton;

View File

@@ -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);
}

View 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;

View File

@@ -0,0 +1 @@
export { default, PoseRenderer } from './PoseRenderer';

View 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;

View 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;

View 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
View 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>,
)

View 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;
}
}

View 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;

View 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); }
}

View 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;

View 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%;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;

View 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;

View 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;

View 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
View File

@@ -0,0 +1,4 @@
declare module '*.scss' {
const content: { [className: string]: string };
export default content;
}

View 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);
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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
View 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
View 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