From d9d743bec24e6ffa371f250c3a169b70acfaef41 Mon Sep 17 00:00:00 2001 From: lashman Date: Wed, 8 Apr 2026 01:21:17 +0300 Subject: [PATCH] multiplayer panel --- frontend/src/components/MultiplayerPanel.tsx | 388 +++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 frontend/src/components/MultiplayerPanel.tsx diff --git a/frontend/src/components/MultiplayerPanel.tsx b/frontend/src/components/MultiplayerPanel.tsx new file mode 100644 index 0000000..43c5d97 --- /dev/null +++ b/frontend/src/components/MultiplayerPanel.tsx @@ -0,0 +1,388 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { createPortal } from 'react-dom' +import { motion, AnimatePresence } from 'motion/react' +import { IconX, IconMessagesFilled, IconCopyFilled, + IconCircleFilled, IconBoltFilled, IconHeartFilled, + IconArrowBigRightFilled, IconClockFilled, IconPlayerPlayFilled, + IconConfettiFilled, IconChevronDownFilled } from '@tabler/icons-react' +import confetti from 'canvas-confetti' +import { useStore } from '../store' +import { useFocusTrap } from '../hooks/useFocusTrap' + +interface RecentRoom { + code: string + mode: string + ts: number +} + +function loadRecentRooms(): RecentRoom[] { + try { + return JSON.parse(localStorage.getItem('mp-recent-rooms') || '[]') + } catch { return [] } +} + +function saveRecentRoom(code: string, mode: string) { + const rooms = loadRecentRooms().filter(r => r.code !== code) + rooms.unshift({ code, mode, ts: Date.now() }) + localStorage.setItem('mp-recent-rooms', JSON.stringify(rooms.slice(0, 8))) +} + +export default function MultiplayerPanel({ onClose }: { onClose: () => void }) { + const trapRef = useFocusTrap(true) + const { t } = useTranslation() + const puzzle = useStore(s => s.puzzle) + const mpConnected = useStore(s => s.mpConnected) + const mpRoomCode = useStore(s => s.mpRoomCode) + const mpMode = useStore(s => s.mpMode) + const mpPlayers = useStore(s => s.mpPlayers) + const mpStarted = useStore(s => s.mpStarted) + const mpCurrentTurn = useStore(s => s.mpCurrentTurn) + const mpMyId = useStore(s => s.mpMyId) + const mpIsCreator = useStore(s => s.mpIsCreator) + const mpCreateRoom = useStore(s => s.mpCreateRoom) + const mpJoinRoom = useStore(s => s.mpJoinRoom) + const mpLeaveRoom = useStore(s => s.mpLeaveRoom) + const mpStartGame = useStore(s => s.mpStartGame) + const mpPassTurn = useStore(s => s.mpPassTurn) + + const partySubMode = useStore(s => s.partySubMode) + + const [joinCode, setJoinCode] = useState('') + const [mode, setMode] = useState<'coop' | 'competitive' | 'party'>('coop') + const [partySub, setPartySub] = useState('wrong') + const [partyDropOpen, setPartyDropOpen] = useState(false) + const partyBtnRef = useRef(null) + const partyDropRef = useRef(null) + const [partyDropPos, setPartyDropPos] = useState({ top: 0, left: 0, width: 0 }) + const [err, setErr] = useState(null) + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(false) + const [recentRooms, setRecentRooms] = useState(loadRecentRooms) + + const PARTY_MODES: { id: string; name: string; desc: string }[] = [ + { id: 'wrong', name: t('party.subModes.wrong'), desc: t('multiplayer.partyModes.wrongDesc') }, + { id: 'bluff', name: t('party.subModes.bluff'), desc: t('multiplayer.partyModes.bluffDesc') }, + { id: 'reverse', name: t('party.subModes.reverse'), desc: t('multiplayer.partyModes.reverseDesc') }, + { id: 'speed', name: t('party.subModes.speed'), desc: t('multiplayer.partyModes.speedDesc') }, + { id: 'roulette', name: t('party.subModes.roulette'), desc: t('multiplayer.partyModes.rouletteDesc') }, + ] + + const updatePartyDropPos = useCallback(() => { + if (!partyBtnRef.current) return + const rect = partyBtnRef.current.getBoundingClientRect() + setPartyDropPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }) + }, []) + + useEffect(() => { + if (!partyDropOpen) return + updatePartyDropPos() + const onDown = (e: MouseEvent) => { + const target = e.target + if (target instanceof Node && partyDropRef.current && !partyDropRef.current.contains(target) && + partyBtnRef.current && !partyBtnRef.current.contains(target)) { + setPartyDropOpen(false) + } + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPartyDropOpen(false) } + window.addEventListener('scroll', updatePartyDropPos, true) + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', updatePartyDropPos, true) + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [partyDropOpen, updatePartyDropPos]) + + useEffect(() => { + if (mpRoomCode && mpMode) { + saveRecentRoom(mpRoomCode, mpMode) + queueMicrotask(() => setRecentRooms(loadRecentRooms())) + } + }, [mpRoomCode, mpMode]) + + const handleCreate = async () => { + if (mode !== 'party' && !puzzle) return + setErr(null) + setBusy(true) + try { + await mpCreateRoom(mode, mode === 'party' ? partySub : undefined) + if (mode === 'party') onClose() + } catch (e) { + setErr(e instanceof Error ? e.message : t('multiplayer.createFailed')) + } + setBusy(false) + } + + const handleJoin = async (code?: string) => { + const c = (code || joinCode).trim().toUpperCase() + if (c.length < 4) return + setErr(null) + setBusy(true) + try { + await mpJoinRoom(c) + onClose() + } catch (e) { + setErr(e instanceof Error ? e.message : t('multiplayer.joinFailed')) + } + setBusy(false) + } + + const copyCode = () => { + if (!mpRoomCode) return + navigator.clipboard.writeText(mpRoomCode) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const inRoom = mpConnected && mpRoomCode + const isMyTurn = mpStarted && mpCurrentTurn === mpMyId + const currentPlayer = mpPlayers.find(p => p.id === mpCurrentTurn) + + return ( + + e.stopPropagation()} + role="dialog" aria-modal="true" aria-label={t('multiplayer.playTogether')} + initial={{ opacity: 0, scale: 0.96, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.96, y: 10 }} + transition={{ duration: 0.2 }} + > +
+

{t('multiplayer.playTogether')}

+ +
+ + + {err && ( + + {err} + + )} + + + {!inRoom ? ( +
+
+
{t('multiplayer.createRoom')}
+
+ + + +
+ + {mode === 'party' && ( + setPartyDropOpen(!partyDropOpen)} + initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} + exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }} + style={{ overflow: 'hidden' }}> + {PARTY_MODES.find(m => m.id === partySub)?.name || t('party.subModes.wrong')} + + )} + + {createPortal( + + {partyDropOpen && ( + + {PARTY_MODES.map(m => ( + + ))} + + )} + , + document.body, + )} + +
+ +
+
{t('multiplayer.joinRoom')}
+
+ setJoinCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6))} + placeholder={t('multiplayer.roomCode')} + aria-label={t('multiplayer.roomCode')} + maxLength={6} + spellCheck={false} + autoComplete="off" + /> + +
+
+ + {recentRooms.length > 0 && ( +
+
{t('multiplayer.recentRooms')}
+
+ {recentRooms.map(r => ( + + ))} +
+
+ )} +
+ ) : ( +
+ <> + + +
+ {mpMode === 'party' ? : mpMode === 'coop' ? : } + {mpMode === 'party' ? t('multiplayer.partyLabel') : mpMode === 'coop' ? t('multiplayer.coop') : t('multiplayer.race')} + {mpMode !== 'party' && - {t('multiplayer.turnBased')}} + {mpMode === 'party' && - { + partySubMode === 'bluff' ? t('multiplayer.bluff') : + partySubMode === 'reverse' ? t('multiplayer.reverseClue') : + partySubMode === 'speed' ? t('multiplayer.speedRound') : + partySubMode === 'roulette' ? t('multiplayer.roulette') : + t('multiplayer.wrongAnswersOnly') + }} +
+ + {mpStarted && currentPlayer && mpMode !== 'party' && ( +
+ + {isMyTurn ? t('multiplayer.yourTurn') : t('multiplayer.playerTurn', { name: currentPlayer.name })} +
+ )} + + {!mpStarted && ( +
+
+ )} + +
{t('party.players', { count: mpPlayers.length })}
+
+ {mpPlayers.map(p => ( +
+ + {p.name} + {p.id === mpMyId && {t('multiplayer.you')}} + {mpStarted && p.id === mpCurrentTurn && {t('multiplayer.playing')}} +
+ ))} + {mpPlayers.length === 0 && ( +
{t('multiplayer.waitingForPlayersEllipsis')}
+ )} +
+ +
+ {!mpStarted && mpIsCreator && ( + + )} + {mpStarted && isMyTurn && mpMode !== 'party' && ( + + )} + +
+ +
+ )} +
+
+ ) +}