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