import { create } from 'zustand';
import io from 'socket.io-client';
const useSocketStore = create((set, get) => ({
socket: null,
rooms: [],
initSocket: (url) => {
const newSocket = io(url);
newSocket.on('ROOMS_UPDATE', (rooms) => {
console.log('ROOMS_UPDATE received', rooms);
set({ rooms });
})
set({ socket: newSocket});
},
closeSocket: () => {
const { socket } = get();
if (socket) {
socket.close();
set({ socket: null });
}
},
addRoom: (room, callback) => {
const { socket } = get();
if (socket) {
socket.emit('ADD_ROOM', room, (newRoom) => {
if (callback) callback(newRoom);
});
}
},
joinRoom: (roomId) => {
const { socket } = get();
if (socket) {
socket.emit('join room', roomId);
}
},
emitOffer: (offer, roomId) => {
const { socket } = get();
if (socket) {
socket.emit('offer', { type: offer.type, sdp: offer.sdp, roomId });
}
},
emitAnswer: (answer, roomId) => {
const { socket } = get();
if (socket) {
socket.emit('answer', { type: answer.type, sdp: answer.sdp, roomId });
}
},
emitCandidate: (candidate, roomId) => {
const { socket } = get();
if (socket) {
socket.emit('candidate', { candidate, roomId });
}
},
emitDamage: (damage, roomId) => {
const { socket } = get();
if (socket) {
socket.emit('damage', { roomId: roomId.current, amount: damage });
}
},
}));
export default useSocketStore;
import { create } from 'zustand';
const useGameStore = create((set, get ) => ({
opponentHealth: 100,
playerHealth: 100,
playerName: '',
opponentName: '',
gameStatus: 'playing', // 'idle', 'ready' ,'playing', 'finished', 'replaying'
opponentReady: false,
setOpponentReady: (state) => set({opponentReady: state}),
setPlayerName: (name) => set({ playerName: name }),
setOpponentName: (name) => set({ opponentName: name }),
startGame: () => set({
opponentHealth: 100,
playerHealth: 100,
gameStatus: 'playing'
}),
endGame: () => set({ gameStatus: 'finished' }),
resetGame: () => set({
opponentHealth: 100,
playerHealth: 100,
gameStatus: 'idle'
}),
decreaseOpponentHealth: (amount) => set((state) => ({
opponentHealth: Math.max(0, state.opponentHealth - amount)
})),
decreasePlayerHealth: (amount) => set((state) => ({
playerHealth: Math.max(0, state.playerHealth - amount)
})),
getWinner: () => {
const { playerHealth, opponentHealth, playerName, opponentName } = get();
if (playerHealth <= 0) return opponentName;
if (opponentHealth <= 0) return playerName;
}
}));
export default useGameStore;
'use client';
import useGameStore from '@/store/gameStore';
import useGameLogic from '@/hooks/useGameLogic';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export default function ReadyCanvas({ onReady, landmarks, canvasSize, opponentReady, sendPlayerReadyState }) {
const canvasRef = useRef(null);
const timerRef = useRef(null);
const [similarityResult, setSimilarityResult] = useState(null);
const [remainingTime, setRemainingTime] = useState(5);
const keypoints = useMemo(() => ['nose', 'leftShoulder', 'rightShoulder'], []);
const similarityThreshold = 0.10;
const targetPose = useRef({
nose: { x: 0.5, y: 0.45 },
leftShoulder: { x: 0.7, y: 0.65 },
rightShoulder: { x: 0.3, y: 0.65 }
});
const extractRequiredLandmarks = useCallback((landmarks) => {
const requiredLandmarks = {};
for (const keypoint of keypoints) {
if (landmarks && landmarks[keypoint]) {
requiredLandmarks[keypoint] = landmarks[keypoint];
}
}
return requiredLandmarks;
}, [keypoints]);
const calculatePoseSimilarity = useCallback((detectedPose, targetPose) => {
let totalDistance = 0;
let count = 0;
for (const keypoint of keypoints) {
if (detectedPose[keypoint] && targetPose[keypoint] && detectedPose[keypoint] !== null) {
const dx = detectedPose[keypoint].x - targetPose[keypoint].x;
const dy = detectedPose[keypoint].y - targetPose[keypoint].y;
const distance = Math.sqrt(dx * dx + dy * dy);
totalDistance += distance;
count++;
}
}
if (count === 0) return 0;
const avgDistance = totalDistance / count;
const maxAllowedDistance = 0.15;
const similarity = Math.max(0, 1 - avgDistance / maxAllowedDistance);
return similarity;
}, [keypoints]);
const startTimer = useCallback(() => {
if (timerRef.current) return;
setRemainingTime(5); // 타이머 초기화
timerRef.current = setInterval(() => {
setRemainingTime((prevTime) => {
if (prevTime <= 1) {
clearInterval(timerRef.current);
timerRef.current = null;
setIsReadyPose(true);
onReady();
return 0;
}
return prevTime - 1;
});
}, 1000);
}, [onReady]);
const checkReadyPose = useCallback(() => {
if (landmarks && canvasRef.current) {
const requiredLandmarks = extractRequiredLandmarks(landmarks);
const similarity = calculatePoseSimilarity(requiredLandmarks, targetPose.current);
setSimilarityResult(similarity);
if (similarity >= similarityThreshold) {
console.log(opponentReady, ":", similarity, '>', similarityThreshold);
sendPlayerReadyState(true);
if (opponentReady && !timerRef.current) {
startTimer();
}
} else {
sendPlayerReadyState(false);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
setRemainingTime(5);
}
}
}
}, [landmarks, calculatePoseSimilarity, similarityThreshold, extractRequiredLandmarks, sendPlayerReadyState, opponentReady, startTimer]);
useEffect(() => {
const canvas = canvasRef.current;
canvas.width = canvasSize.width;
canvas.height = canvasSize.height;
}, [canvasSize]);
useEffect(() => {
checkReadyPose();
}, [landmarks, checkReadyPose]);
const [headerText, setHeaderText] = useState("점선에 어깨와 머리를 맞춰주세요");
const [headerColor, setHeaderColor] = useState('white');
useEffect(() => {
setHeaderText(timerRef.current ? "거리를 유지해주세요" : "점선에 어깨와 머리를 맞춰주세요");
}, []);
useEffect(() => {
const checkTimer = () => {
setHeaderText(timerRef.current ? "거리를 유지해주세요" : "점선에 어깨와 머리를 맞춰주세요");
};
checkTimer();
const intervalId = setInterval(checkTimer, 100);
return () => clearInterval(intervalId);
}, []);
useEffect(() => {
if (similarityResult >= 0.7) setHeaderColor('green');
else if (similarityResult >= 0.5) setHeaderColor('yellow');
else setHeaderColor('red');
}, [similarityResult]);
return (
<div className="relative w-full h-full">
<div className="ready-header" style={{ '--header-color': headerColor }}>
{headerText}
</div>
<canvas ref={canvasRef} className="absolute inset-0" />
<div className="similarity-info">
Similarity: {similarityResult ? similarityResult.toFixed(2) : 'N/A'}
</div>
{(timerRef.current && remainingTime <= 4) && (
<div className="countdown-text">
{remainingTime === 1 ? 'GO!' : remainingTime - 1}
</div>
)}
</div>
);
}
'use client';
import { forwardRef, useRef, useEffect, useState, useCallback, useImperativeHandle } from 'react'
import { useFrame} from '@react-three/fiber'
import { useGLTF } from '@react-three/drei'
import * as THREE from 'three'
import useGameStore from '../../store/gameStore';
import useSocketStore from '@/store/socketStore';
// Opponent 전용 Head 컴포넌트
const OpponentHead = forwardRef(({ position, rotation, scale, name, hit }, ref) => {
const localRef = useRef()
const { scene, materials } = useGLTF('/models/opponent-head.glb')
const opacity = 0.9
useImperativeHandle(
ref,
() => ({
getWorldPosition: (target) => {
if (localRef.current) {
return localRef.current.getWorldPosition(target || new THREE.Vector3())
}
return new THREE.Vector3()
},
position: localRef.current?.position,
rotation: localRef.current?.rotation,
}),
[localRef],
)
useEffect(() => {
if (localRef.current) {
localRef.current.position.set((position.x - 0.5) * 5, -(position.y - 0.5) * 5, -(position.z+0.01) * 15)
if (rotation) {
localRef.current.rotation.set(rotation[0], rotation[1], rotation[2]);
}
}
}, [position, rotation])
useEffect(() => {
Object.values(materials).forEach((material) => {
material.transparent = true
material.opacity = opacity
material.color.setRGB(hit ? 1 : 1, hit ? 0 : 1, hit ? 0 : 1) // Set color to red when hit
})
}, [materials, hit])
return <primitive ref={localRef} object={scene} scale={scale} name={name} />
})
OpponentHead.displayName = "OpponentHead";
// Opponent 전용 Hand 컴포넌트
const OpponentHand = forwardRef(({ position, rotation, scale, name }, ref) => {
const localRef = useRef()
const { scene } = useGLTF(name === 'opponentLeftHand' ? '/models/left-hand.glb' : '/models/right-hand.glb')
useEffect(() => {
scene.traverse((child) => {
if (child.isMesh) {
child.material = child.material.clone()
child.material.color.set('red')
}
});
}, [scene]);
useFrame(() => {
if (localRef.current && position) {
if (rotation) {
localRef.current.rotation.set(rotation[0],rotation[1],rotation[2])
}
localRef.current.position.set((position[0]-0.5)*4, -(position[1]-0.5)*4, -position[2]*30)
}
})
return <primitive ref={localRef} object={scene.clone()} scale={scale} name={name} />
})
OpponentHand.displayName = "OpponentHand";
export function Opponent({ position, landmarks, opponentData }) {
const groupRef = useRef(null)
const headRef = useRef(null)
const [hit, setHit] = useState(false)
const lastHitTime = useRef(0)
const decreaseOpponentHealth = useGameStore((state) => state.decreaseOpponentHealth)
const count_optm = useRef(0)
const roomId = useRef('')
const hitSoundRef = useRef(null);
const emitDamage = useSocketStore(state=>state.emitDamage);
useEffect(() => {
hitSoundRef.current = new Audio('./sounds/hit.MP3');
}, []);
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
roomId.current = searchParams.get('roomId');
}, [roomId]);
const playHitSound = useCallback(() => {
if (hitSoundRef.current) {
hitSoundRef.current.play();
}
}, []);
const checkHit = useCallback(() => {
if(!headRef.current || !landmarks.leftHand || !landmarks.rightHand) return
const headPosition = new THREE.Vector3()
headRef.current.getWorldPosition(headPosition)
const hands = [landmarks.leftHand, landmarks.rightHand]
const currentTime = performance.now()
hands.forEach((hand, index) => {
// console.log(hand[2])
if(hand[2] !== 0) return // 주먹상태인지 확인
const handPosition = new THREE.Vector3(
(hand[0][0] - 0.5) * 4,
-(hand[0][1] - 0.5) * 4,
-(hand[0][2] * 30)
)
// Opponent의 회전을 적용합니다 (y축 주위로 180도 회전).
// handPosition.applyAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI);
// Player의 위치 오프셋을 적용합니다.
const distanceAdjustment = new THREE.Vector3(0, 0, -2.5); // 1 - (-5) = 6
handPosition.add(distanceAdjustment);
const distance = headPosition.distanceTo(handPosition)
if (distance < 1.3 && currentTime - lastHitTime.current > 1000) {
const velocity = hand[0].reduce((sum, coord) => sum + Math.abs(coord), 0)
const damage = Math.floor(velocity * 10)
setHit(true)
decreaseOpponentHealth(damage)
// 데미지 정보를 서버로 전송
emitDamage(damage, roomId);
playHitSound()
lastHitTime.current = currentTime
setTimeout(() => setHit(false), 200)
// console.log('===velocity:', velocity, 'damage:',damage)
}
// console.log('distance:', distance)
})
}, [landmarks, decreaseOpponentHealth, playHitSound])
useFrame(() => {
if(count_optm.current % 10 === 0) {
checkHit()
}
if (count_optm.current > 1000000) count_optm.current = 0;
count_optm.current++;
// console.log('myhead',landmarks?.current?.head?.[0])
})
return (
<group ref={groupRef} position={position} rotation={[0, Math.PI, 0]}>
{opponentData.head && (
<OpponentHead
ref={headRef}
position={new THREE.Vector3(opponentData.head[0][0], opponentData.head[0][1], opponentData.head[0][2])}
rotation={[0, -opponentData.head[1][1] * (Math.PI / 180), -opponentData.head[1][2] * (Math.PI / 180)]}
scale={0.25}
name='opponentHead'
hit={hit}
/>)}
{opponentData.rightHand && (
<OpponentHand
position={opponentData.rightHand[0]}
rotation={[(opponentData.rightHand[1][0]-Math.PI), Math.PI , -(opponentData.rightHand[1][1]-Math.PI/2)]}
scale={0.33}
name='opponentRightHand'
/>
)}
{opponentData.leftHand && (
<OpponentHand
position={opponentData.leftHand[0]}
rotation={[-(opponentData.leftHand[1][0]-Math.PI), 0 ,-(opponentData.leftHand[1][1]+Math.PI/2)]}
scale={0.33}
name='opponentLeftHand'
/>
)}
</group>
);
}
// 모델 미리 로드
useGLTF.preload('/models/head.glb');
useGLTF.preload('/models/left-hand.glb');
useGLTF.preload('/models/right-hand.glb');
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import useSocketStore from '@/store/socketStore';
import useGameStore from '@/store/gameStore';
const useGameLogic = () => {
const { roomId } = useParams();
const {
playerName,
opponentName,
setPlayerName,
opponentReady,
setOpponentName,
setOpponentReady,
startGame,
gameStatus,
endGame,
resetGame,
decreasePlayerHealth,
decreaseOpponentHealth,
getWinner
} = useGameStore();
// 소켓 연결 설정
const socket = useSocketStore(state => state.socket);
// 소켓 이벤트 리스너 설정
useEffect(() => {
if (!socket) return;
socket.on('gameUpdate', handleGameUpdate);
socket.on('playerJoined', handlePlayerJoined);
socket.on('playerLeft', handlePlayerLeft);
socket.on('gameStart', handleGameStart);
socket.on('roundEnd', handleRoundEnd);
socket.on('gameEnd', handleGameEnd);
socket.on('damage', handleDamage);
socket.on('ready', opponentReadyState);
// 방에 입장
socket.emit('joinRoom', roomId);
return () => {
socket.off('gameUpdate');
socket.off('playerJoined');
socket.off('playerLeft');
socket.off('gameStart');
socket.off('roundEnd');
socket.off('gameEnd');
socket.off('ready');
};
}, [socket, roomId]);
// 게임 상태 업데이트 처리
const handleGameUpdate = useCallback((newState) => {
setGameState(newState);
}, []);
// 플레이어 입장 처리
const handlePlayerJoined = useCallback((player) => {
setGameState(prev => ({
...prev,
players: { ...prev.players, [player.id]: player }
}));
}, []);
// 플레이어 퇴장 처리
const handlePlayerLeft = useCallback((playerId) => {
setGameState(prev => {
const newPlayers = { ...prev.players };
delete newPlayers[playerId];
return { ...prev, players: newPlayers };
});
}, []);
// 게임 시작 처리
const handleGameStart = useCallback(() => {
setGameState(prev => ({ ...prev, gameStarted: true }));
}, []);
// 라운드 종료 처리
const handleRoundEnd = useCallback((roundResult) => {
setGameState(prev => ({ ...prev, round: prev.round + 1 }));
// 라운드 결과에 따른 추가 로직
}, []);
// 게임 종료 처리
const handleGameEnd = useCallback((result) => {
setGameState(prev => ({ ...prev, gameStarted: false, winner: result.winner }));
}, []);
// 액션 수행 (예: 펀치)
// const performAction = useCallback((action) => {
// if (socket) {
// socket.emit('playerAction', { roomId, playerId: localPlayer, action });
// }
// }, [socket, roomId, localPlayer]);
// 펀치
const handleDamage = useCallback((data) => {
if (socket) {
if (gameStatus === "playing"){
decreasePlayerHealth(data.amount);
}
}
}, [socket, decreasePlayerHealth])
// 상대플레이어 상태 받기
const opponentReadyState = useCallback((data) => {
if (socket) {
setOpponentReady(data.state);
}
}, [socket])
// 플레이어 상태 보내기
const sendPlayerReadyState = useCallback((state)=>{
if (socket) {
socket.emit('ready', {roomId: roomId, data: state})
}
}, [socket, roomId])
return {
opponentReady,
sendPlayerReadyState,
};
};
export default useGameLogic;