From aa938f725847e17091dfa4f4432062b25cfacf74 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Mon, 23 Jun 2025 15:33:04 -0400 Subject: [PATCH] this is a pain in the ass --- app/[gameID]/page.tsx | 3 + hooks/useChatGPT.ts | 95 ++++++++++++++++++++++++++++++ hooks/useRTC.ts | 54 +++++++++++++++++ hooks/useSocket.ts | 88 +++++++++++++++++++++------- lib/RTCPeer.ts | 131 ++++++++++++++++++++++++++++++++++++++++++ server.ts | 26 +++++++++ 6 files changed, 377 insertions(+), 20 deletions(-) create mode 100644 hooks/useChatGPT.ts create mode 100644 hooks/useRTC.ts create mode 100644 lib/RTCPeer.ts diff --git a/app/[gameID]/page.tsx b/app/[gameID]/page.tsx index 32152b0..6b82949 100644 --- a/app/[gameID]/page.tsx +++ b/app/[gameID]/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import useSocket from '@/hooks/useSocket'; +import useRTC from '@/hooks/useRTC'; import { Eye } from 'lucide-react'; import Card from '@/components/Card'; @@ -40,6 +41,8 @@ export default function GamePage() { const selectDeck: Deck | null = selectCard >= 0 ? cards[selectCard].deck : null; const socket = useSocket({ gameID, setGameData, setNoGame }); + const rtc = useRTC(socket); + console.log('useRTC:', rtc); useEffect(() => { if (gameIDParam) { diff --git a/hooks/useChatGPT.ts b/hooks/useChatGPT.ts new file mode 100644 index 0000000..6ac8834 --- /dev/null +++ b/hooks/useChatGPT.ts @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react'; +import { io, Socket } from 'socket.io-client'; + +interface CursorPosition { + x: number; + y: number; +} + +interface PeerMouseHook { + cursors: Record; +} + +export function usePeerMouse(roomId: string): PeerMouseHook { + const [cursors, setCursors] = useState>({}); + const socketRef = useRef(null); + const peers = useRef>({}); + const channels = useRef>({}); + + useEffect(() => { + const socket = io(); + socketRef.current = socket; + + socket.emit('join-room', roomId); + + socket.on('new-peer', async (peerId: string) => { + const pc = createPeer(peerId, true); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + socket.emit('signal', { to: peerId, data: { description: pc.localDescription } }); + }); + + socket.on('signal', async ({ from, data }) => { + const pc = peers.current[from] || createPeer(from, false); + + if (data.description) { + await pc.setRemoteDescription(data.description); + + if (data.description.type === 'offer') { + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + socket.emit('signal', { to: from, data: { description: pc.localDescription } }); + } + } + + if (data.candidate) { + await pc.addIceCandidate(data.candidate); + } + }); + + function createPeer(peerId: string, isInitiator: boolean): RTCPeerConnection { + const pc = new RTCPeerConnection(); + + if (isInitiator) { + const channel = pc.createDataChannel('mouse'); + setupChannel(peerId, channel); + } else { + pc.ondatachannel = (e) => setupChannel(peerId, e.channel); + } + + pc.onicecandidate = (e) => { + if (e.candidate) { + socket.emit('signal', { to: peerId, data: { candidate: e.candidate } }); + } + }; + + peers.current[peerId] = pc; + return pc; + } + + function setupChannel(peerId: string, channel: RTCDataChannel) { + channels.current[peerId] = channel; + channel.onmessage = (e) => { + const pos = JSON.parse(e.data); + setCursors((prev) => ({ ...prev, [peerId]: pos })); + }; + } + + function handleMouseMove(e: MouseEvent) { + const pos = JSON.stringify({ x: e.clientX, y: e.clientY }); + Object.values(channels.current).forEach((ch) => { + if (ch.readyState === 'open') ch.send(pos); + }); + } + + window.addEventListener('mousemove', handleMouseMove); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + socket.disconnect(); + Object.values(peers.current).forEach((pc) => pc.close()); + }; + }, [roomId]); + + return { cursors }; +} diff --git a/hooks/useRTC.ts b/hooks/useRTC.ts new file mode 100644 index 0000000..9984048 --- /dev/null +++ b/hooks/useRTC.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import RTCPeer from '@/lib/RTCPeer'; + +import type { UseSocket } from '@/hooks/useSocket'; + +import type {} from '@/types'; + +// interface UseSocketProps { +// gameID: string; +// setGameData: (gameUpdate: GameUpdate) => void; +// setNoGame: (noGame: boolean) => void; +// } + +const channelName = 'tilt'; + +export default function useRTC({ + ready, + registerAnsweredReceiver, + registerOfferredReceiver, + rtcAnswer: sendAnswer, + rtcOffer: sendOffer, +}: UseSocket) { + const [peers, setPeers] = useState([]); + + const answerHandler = (answer: RTCSessionDescriptionInit) => { + console.log('[useRTC] answer received', answer); + console.log('[useRTC] peers:', peers.length); + const peer = peers[0]; + console.log('peer:', peer); + peer.onAnswer(answer); + }; + + const offerHandler = (offer: RTCSessionDescriptionInit) => { + console.log('[useRTC] offer received', offer); + setPeers((peers) => { + peers.push(new RTCPeer({ channelName, offer, sendAnswer, sendOffer })); + return peers; + }); + }; + + useEffect(() => { + if (ready) { + console.log('-=-= SETTING THINGS UP =-=-'); + registerAnsweredReceiver(answerHandler); + registerOfferredReceiver(offerHandler); + + setPeers([new RTCPeer({ channelName, sendAnswer, sendOffer })]); + } + }, [ready]); + + return { + count: peers.length, + }; +} diff --git a/hooks/useSocket.ts b/hooks/useSocket.ts index 794c8fe..6bcf15c 100644 --- a/hooks/useSocket.ts +++ b/hooks/useSocket.ts @@ -1,20 +1,37 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { socket } from '@/socket'; import type { GameUpdate } from '@/types'; -interface UseSocketProps { +export interface UseSocketProps { gameID: string; setGameData: (gameUpdate: GameUpdate) => void; setNoGame: (noGame: boolean) => void; } -export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps) { +export interface UseSocket { + ready: boolean; + flipCard: (cardIndex: number) => void; + handleSettings: (cardData: GameUpdate) => void; + redraw: (cardIndex: number) => void; + rtcAnswer: (answer: RTCSessionDescriptionInit) => void; + registerAnsweredReceiver: (receiver: (answer: RTCSessionDescriptionInit) => void) => void; + rtcOffer: (offer: RTCSessionDescriptionInit) => void; + registerOfferredReceiver: (receiver: (offer: RTCSessionDescriptionInit) => void) => void; + select: (cardIndex: number, cardID: string) => void; +} + +export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps): UseSocket { + const [ready, setReady] = useState(false); + const answerRef = useRef<(answer: RTCSessionDescriptionInit) => void>(null); + const offerRef = useRef<(offer: RTCSessionDescriptionInit) => void>(null); + useEffect(() => { if (gameID) { socket.emit('join', gameID); socket.on('init', (data: GameUpdate) => { + setReady(true); setGameData(data); }); @@ -30,6 +47,16 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP socket.on('flip-error', (error) => { console.error('Error:', error); }); + + socket.on('rtc-answered', (answered: RTCSessionDescriptionInit) => { + if (answerRef.current) answerRef.current(answered); + }); + + socket.on('rtc-offered', (offered: RTCSessionDescriptionInit) => { + if (offerRef.current) { + offerRef.current(offered); + } + }); } return () => { @@ -38,27 +65,16 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }, [gameID]); const flipCard = (cardIndex: number) => { + console.log('flip-card', { + gameID, + cardIndex, + }); socket.emit('flip-card', { gameID, cardIndex, }); }; - const redraw = (cardIndex: number) => { - socket.emit('redraw', { - gameID, - cardIndex, - }); - }; - - const select = (cardIndex: number, cardID: string) => { - socket.emit('select', { - gameID, - cardIndex, - cardID, - }); - }; - const handleSettings = (gameData: GameUpdate) => { socket.emit('settings', { gameID, @@ -66,10 +82,42 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }); }; + const redraw = (cardIndex: number) => { + socket.emit('redraw', { + gameID, + cardIndex, + }); + }; + + const rtcAnswer = (answer: RTCSessionDescriptionInit) => { + console.log('rtc-answer', { gameID, answer }); + socket.emit('rtc-answer', { gameID, answer }); + }; + + const rtcOffer = (offer: RTCSessionDescriptionInit) => { + console.log('rtc-offer', { gameID, offer }); + socket.emit('rtc-offer', { gameID, offer }); + }; + + const select = (cardIndex: number, cardID: string) => { + socket.emit('select', { + gameID, + cardIndex, + cardID, + }); + }; + return { + ready, flipCard, - redraw, - select, handleSettings, + redraw, + rtcAnswer, + registerAnsweredReceiver: (receiver: (obj: RTCSessionDescriptionInit) => void[]) => + (answerRef.current = receiver), + rtcOffer, + registerOfferredReceiver: (receiver: (obj: RTCSessionDescriptionInit) => void[]) => + (offerRef.current = receiver), + select, }; } diff --git a/lib/RTCPeer.ts b/lib/RTCPeer.ts new file mode 100644 index 0000000..83302ef --- /dev/null +++ b/lib/RTCPeer.ts @@ -0,0 +1,131 @@ +const servers = { + iceServers: [ + { url: 'stun:stun01.sipphone.com' }, + { url: 'stun:stun.ekiga.net' }, + { url: 'stun:stun.fwdnet.net' }, + { url: 'stun:stun.ideasip.com' }, + { url: 'stun:stun.iptel.org' }, + { url: 'stun:stun.rixtelecom.se' }, + { url: 'stun:stun.schlund.de' }, + { url: 'stun:stun.l.google.com:19302' }, + { url: 'stun:stun1.l.google.com:19302' }, + { url: 'stun:stun2.l.google.com:19302' }, + { url: 'stun:stun3.l.google.com:19302' }, + { url: 'stun:stun4.l.google.com:19302' }, + { url: 'stun:stunserver.org' }, + { url: 'stun:stun.softjoys.com' }, + { url: 'stun:stun.voiparound.com' }, + { url: 'stun:stun.voipbuster.com' }, + { url: 'stun:stun.voipstunt.com' }, + { url: 'stun:stun.voxgratia.org' }, + { url: 'stun:stun.xten.com' }, + + // { + // url: 'turn:numb.viagenie.ca', + // credential: 'muazkh', + // username: 'webrtc@live.com', + // }, + // { + // url: 'turn:192.158.29.39:3478?transport=udp', + // credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', + // username: '28224511:1379330808', + // }, + // { + // url: 'turn:192.158.29.39:3478?transport=tcp', + // credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', + // username: '28224511:1379330808', + // }, + ], +}; + +const pcConstraints = { + optional: [{ DtlsSrtpKeyAgreement: true }], +}; + +export interface RTCPeerProps { + channelName: string; + offer?: RTCSessionDescriptionInit; + sendAnswer: (offer: RTCSessionDescriptionInit) => void; + sendOffer: (offer: RTCSessionDescriptionInit) => void; +} + +export default class RTCPeer { + channelName: string; + peerConnection: RTCPeerConnection; + channel: RTCDataChannel; + + sendAnswer: (offer: RTCSessionDescriptionInit) => void; + sendOffer: (offer: RTCSessionDescriptionInit) => void; + + constructor({ channelName, offer, sendAnswer, sendOffer }: RTCPeerProps) { + this.sendOffer = sendOffer; + this.sendAnswer = sendAnswer; + this.channelName = channelName; + + this.peerConnection = new RTCPeerConnection(); //(servers, pcConstraints); + this.peerConnection.onicecandidate = offer + ? this.#handleIceCandidateAnswer + : this.#handleIceCandidateOffer; + + this.#createDataChannel(); + + if (offer) { + console.log('answer'); + this.peerConnection.setRemoteDescription(offer); + this.peerConnection.createAnswer().then((answer) => { + this.peerConnection.setLocalDescription(answer); + }); + } else { + console.log('call'); + this.peerConnection.createOffer().then((offer) => { + this.peerConnection.setLocalDescription(offer); + }); + } + } + + onAnswer = (answer: RTCSessionDescriptionInit) => { + this.peerConnection.setRemoteDescription(answer); + }; + + #handleIceCandidateAnswer = (event: RTCPeerConnectionIceEvent) => { + if (!event.candidate) { + const answer = this.peerConnection.localDescription; + + console.log('send-answer', { answer }); + if (answer) { + this.sendAnswer(answer); + } + } + }; + + #handleIceCandidateOffer = (event: RTCPeerConnectionIceEvent) => { + if (!event.candidate) { + const offer = this.peerConnection.localDescription; + + if (offer) { + console.log('send-offer', { offer }); + this.sendOffer(offer); + } + } + }; + + #createDataChannel = () => { + try { + this.channel = this.peerConnection.createDataChannel(this.channelName); + + this.channel.onopen = () => { + console.log('Receive Channel[onopen]:', this.channel.readyState); + }; + + this.channel.onmessage = (event: MessageEvent) => { + console.log('Receive Channel[onmessage]:', event.data); + }; + + this.channel.onclose = () => { + console.log('Receive Channel[onclose]:', this.channel.readyState); + }; + } catch (error) { + console.error('[RTCPeer|#createDataChannel] ERROR', error); + } + }; +} diff --git a/server.ts b/server.ts index 26890bc..0dff646 100644 --- a/server.ts +++ b/server.ts @@ -119,6 +119,32 @@ app.prepare().then(() => { } }); + socket.on('rtc-answer', ({ gameID, answer }: { gameID: string; answer: any }) => { + try { + const gameState = gameStore.getGame(gameID); + console.log('[rtc-answer]', gameID); + + io.to(gameState.dmID).emit('rtc-answered', answer); + io.to(gameState.spectatorID).emit('rtc-answered', answer); + } catch (e) { + const error = e instanceof Error ? e.message : e; + console.error(Date.now(), 'Error[rtc-answer]', error); + } + }); + + socket.on('rtc-offer', ({ gameID, offer }: { gameID: string; offer: any }) => { + try { + const gameState = gameStore.getGame(gameID); + console.log('[rtc-offer]', gameID); + + io.to(gameState.dmID).emit('rtc-offered', offer); + io.to(gameState.spectatorID).emit('rtc-offered', offer); + } catch (e) { + const error = e instanceof Error ? e.message : e; + console.error(Date.now(), 'Error[rtc-offer]', error); + } + }); + socket.on('disconnect', () => { try { const game = gameStore.playerExit(socket.id);