multiplayer panel
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user