multiplayer panel

This commit is contained in:
2026-04-08 01:21:17 +03:00
parent e34ec9c95a
commit d9d743bec2
@@ -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<string>('wrong')
const [partyDropOpen, setPartyDropOpen] = useState(false)
const partyBtnRef = useRef<HTMLButtonElement>(null)
const partyDropRef = useRef<HTMLDivElement>(null)
const [partyDropPos, setPartyDropPos] = useState({ top: 0, left: 0, width: 0 })
const [err, setErr] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState(false)
const [recentRooms, setRecentRooms] = useState<RecentRoom[]>(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 (
<motion.div className="settings-overlay" onClick={onClose}
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
<motion.div
ref={trapRef}
className="mp-panel"
onClick={e => 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 }}
>
<div className="mp-header">
<h3><IconMessagesFilled size={16} /> {t('multiplayer.playTogether')}</h3>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}>
<IconX size={14} aria-hidden="true" />
</button>
</div>
<AnimatePresence>
{err && (
<motion.div className="mp-error" key="mp-err" role="alert"
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.15 }}>
{err}
</motion.div>
)}
</AnimatePresence>
{!inRoom ? (
<div className="mp-lobby">
<div className="mp-section">
<div className="mp-section-title">{t('multiplayer.createRoom')}</div>
<div className="mp-mode-picker mp-mode-picker-3">
<button
className={`mp-mode-btn ${mode === 'coop' ? 'mp-mode-active' : ''}`}
onClick={() => setMode('coop')}
>
<IconHeartFilled size={16} />
<span>{t('multiplayer.coop')}</span>
</button>
<button
className={`mp-mode-btn ${mode === 'competitive' ? 'mp-mode-active' : ''}`}
onClick={() => setMode('competitive')}
>
<IconBoltFilled size={16} />
<span>{t('multiplayer.race')}</span>
</button>
<button
className={`mp-mode-btn ${mode === 'party' ? 'mp-mode-active' : ''}`}
onClick={(e) => {
const wasParty = mode === 'party'
setMode('party')
if (wasParty) {
// already selected, do nothing
} else {
const rect = e.currentTarget.getBoundingClientRect()
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:9999'
document.body.appendChild(canvas)
const myConfetti = confetti.create(canvas, { resize: true })
myConfetti({
particleCount: 60,
spread: 70,
startVelocity: 25,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: (rect.top + rect.height / 2) / window.innerHeight,
},
}).then(() => canvas.remove())
}
}}
>
<IconConfettiFilled size={16} />
<span>{t('multiplayer.partyLabel')}</span>
</button>
</div>
<AnimatePresence>
{mode === 'party' && (
<motion.button type="button" key="party-sub" ref={partyBtnRef} className="mp-party-sub-selected"
onClick={() => 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')}
<IconChevronDownFilled size={10} style={{ transform: partyDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 150ms' }} aria-hidden="true" />
</motion.button>
)}
</AnimatePresence>
{createPortal(
<AnimatePresence>
{partyDropOpen && (
<motion.div
ref={partyDropRef}
className="mp-party-dropdown"
style={{ top: partyDropPos.top, left: partyDropPos.left, width: partyDropPos.width }}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
>
{PARTY_MODES.map(m => (
<button
key={m.id}
className={`mp-party-drop-item ${partySub === m.id ? 'mp-party-drop-active' : ''}`}
onClick={() => { setPartySub(m.id); setPartyDropOpen(false) }}
>
<span className="mp-party-drop-name">{m.name}</span>
<span className="mp-party-drop-desc">{m.desc}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
<button
className="btn btn-primary"
style={{ width: '100%' }}
onClick={handleCreate}
disabled={busy || (mode !== 'party' && !puzzle)}
>
{busy ? t('multiplayer.creating') : mode === 'party' ? t('multiplayer.startParty') : !puzzle ? t('multiplayer.loadPuzzleFirst') : t('multiplayer.createRoomBtn')}
</button>
</div>
<div className="mp-section">
<div className="mp-section-title">{t('multiplayer.joinRoom')}</div>
<div className="mp-join-row">
<input
className="mp-code-input"
value={joinCode}
onChange={e => 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"
/>
<button
className="btn btn-primary"
onClick={() => handleJoin()}
disabled={busy || joinCode.length < 4}
>
<IconArrowBigRightFilled size={14} />
</button>
</div>
</div>
{recentRooms.length > 0 && (
<div className="mp-section">
<div className="mp-section-title">{t('multiplayer.recentRooms')}</div>
<div className="mp-recent">
{recentRooms.map(r => (
<button
key={r.code}
className="mp-recent-item"
onClick={() => handleJoin(r.code)}
disabled={busy}
>
<span className="mp-recent-code">{r.code}</span>
<span className="mp-recent-mode">{r.mode}</span>
<IconClockFilled size={10} aria-hidden="true" />
</button>
))}
</div>
</div>
)}
</div>
) : (
<div className="mp-room">
<>
<button type="button" className="mp-code-display" onClick={copyCode} aria-label={t('multiplayer.roomCode')}>
<span className="mp-code-label">{t('multiplayer.roomCode')}</span>
<span className="mp-code-value">{mpRoomCode}</span>
<span className="mp-code-copy">
{copied ? <span role="status">{t('multiplayer.copied')}</span> : <IconCopyFilled size={14} aria-hidden="true" />}
</span>
</button>
<div className="mp-room-mode">
{mpMode === 'party' ? <IconConfettiFilled size={14} /> : mpMode === 'coop' ? <IconHeartFilled size={14} /> : <IconBoltFilled size={14} />}
{mpMode === 'party' ? t('multiplayer.partyLabel') : mpMode === 'coop' ? t('multiplayer.coop') : t('multiplayer.race')}
{mpMode !== 'party' && <span className="mp-turn-label">- {t('multiplayer.turnBased')}</span>}
{mpMode === 'party' && <span className="mp-turn-label">- {
partySubMode === 'bluff' ? t('multiplayer.bluff') :
partySubMode === 'reverse' ? t('multiplayer.reverseClue') :
partySubMode === 'speed' ? t('multiplayer.speedRound') :
partySubMode === 'roulette' ? t('multiplayer.roulette') :
t('multiplayer.wrongAnswersOnly')
}</span>}
</div>
{mpStarted && currentPlayer && mpMode !== 'party' && (
<div className={`mp-turn-indicator ${isMyTurn ? 'mp-your-turn' : ''}`}>
<IconCircleFilled size={8} style={{ color: currentPlayer.color }} />
{isMyTurn ? t('multiplayer.yourTurn') : t('multiplayer.playerTurn', { name: currentPlayer.name })}
</div>
)}
{!mpStarted && (
<div className="mp-status">
<span className={`mp-status-dot ${mpConnected ? 'mp-connected' : 'mp-disconnected'}`} aria-hidden="true" />
{mpConnected ? t('multiplayer.waitingToStart') : t('multiplayer.disconnected')}
</div>
)}
<div className="mp-section-title">{t('party.players', { count: mpPlayers.length })}</div>
<div className="mp-player-list">
{mpPlayers.map(p => (
<div key={p.id} className={`mp-player ${mpStarted && p.id === mpCurrentTurn ? 'mp-player-active' : ''}`}>
<IconCircleFilled size={10} style={{ color: p.color }} />
<span className="mp-player-name">{p.name}</span>
{p.id === mpMyId && <span className="mp-player-you">{t('multiplayer.you')}</span>}
{mpStarted && p.id === mpCurrentTurn && <span className="mp-player-turn">{t('multiplayer.playing')}</span>}
</div>
))}
{mpPlayers.length === 0 && (
<div className="mp-empty">{t('multiplayer.waitingForPlayersEllipsis')}</div>
)}
</div>
<div className="mp-actions">
{!mpStarted && mpIsCreator && (
<button className="btn btn-primary btn-sm" onClick={mpStartGame} disabled={mpPlayers.length < 2} style={{ width: '100%' }}>
<IconPlayerPlayFilled size={14} /> {mpPlayers.length < 2 ? t('multiplayer.needMorePlayers') : t('multiplayer.startGame')}
</button>
)}
{mpStarted && isMyTurn && mpMode !== 'party' && (
<button className="btn btn-sm" onClick={mpPassTurn} style={{ width: '100%' }}>
{t('multiplayer.passTurn')}
</button>
)}
<button className="btn btn-sm mp-leave" onClick={() => { mpLeaveRoom(); }}>
<IconX size={14} /> {t('multiplayer.leaveRoom')}
</button>
</div>
</>
</div>
)}
</motion.div>
</motion.div>
)
}