diff --git a/assets/css/mineseeker/_waiting-dialog.scss b/assets/css/mineseeker/_waiting-dialog.scss index 8b9983b..3f6d868 100644 --- a/assets/css/mineseeker/_waiting-dialog.scss +++ b/assets/css/mineseeker/_waiting-dialog.scss @@ -305,3 +305,43 @@ padding-top: 14px; border-top: 1px solid rgba(35, 111, 135, 0.14); } + +.opd-header-actions { + .opd-refresh[disabled] { + opacity: 0.4; + cursor: not-allowed; + } + + .opd-close[disabled] { + opacity: 0.4; + cursor: not-allowed; + } +} + +.opd-waiting { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + margin-bottom: 16px; + background: rgba(35, 111, 135, 0.07); + border: 1px solid rgba(35, 111, 135, 0.28); + border-radius: 8px; + color: #95cff5; + + i { + font-size: 16px; + animation: opd-hourglass 1s ease-in-out infinite; + } + + p { + margin: 0; + font: 600 14px 'Rajdhani', sans-serif; + letter-spacing: 0.5px; + } +} + +@keyframes opd-hourglass { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(180deg); } +} diff --git a/assets/js/mine-seeker/components/ChallengeCountdown.jsx b/assets/js/mine-seeker/components/ChallengeCountdown.jsx new file mode 100644 index 0000000..6719d98 --- /dev/null +++ b/assets/js/mine-seeker/components/ChallengeCountdown.jsx @@ -0,0 +1,41 @@ +/** + * This file is part of the SplendidBear Websites' projects. + * + * Copyright (c) 2026 @ www.splendidbear.org + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +import { Fragment, useEffect, useState } from 'react'; + +const ChallengeCountdown = ({ onAccept, onDecline, seconds = 30 }) => { + const [countdown, setCountdown] = useState(seconds); + + useEffect(() => { + const interval = setInterval(() => { + setCountdown(prev => { + if (1 >= prev) { + clearInterval(interval); + onDecline(); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(interval); + }, [onDecline]); + + return ( + +

+ You have {countdown} second{1 === countdown ? '' : 's'} to answer to the challenge! +

+
+ Accept + Decline +
+
+ ); +}; + +export default ChallengeCountdown; diff --git a/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx b/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx index ee1bb4c..a135142 100644 --- a/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx +++ b/assets/js/mine-seeker/components/OnlinePlayersDialog.jsx @@ -47,6 +47,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => { const [snapshotLoaded, setSnapshotLoaded] = useState(false); const [challengingGameAssoc, setChallengingGameAssoc] = useState(null); const [declinedMsg, setDeclinedMsg] = useState(''); + const [waitingCountdown, setWaitingCountdown] = useState(0); const declinedTimerRef = useRef(null); const addPlayer = useCallback(entry => { @@ -111,6 +112,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => { setChallengingGameAssoc(null); clearTimeout(declinedTimerRef.current); setDeclinedMsg('Challenge was not accepted.'); + setWaitingCountdown(0); declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500); }; window.addEventListener('challenge-declined', handler); @@ -120,15 +122,30 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => { }; }, []); + useEffect(() => { + if (!waitingCountdown) return; + const interval = setInterval(() => { + setWaitingCountdown(prev => { + if (1 >= prev) return 0; + return prev - 1; + }); + }, 1000); + return () => clearInterval(interval); + }, [waitingCountdown]); + const handleChallenge = player => { if (challengingGameAssoc) return; setChallengingGameAssoc(player.gameAssoc); setDeclinedMsg(''); + setWaitingCountdown(30); fetch('/api/game/challenge/' + player.gameAssoc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }), - }).catch(() => setChallengingGameAssoc(null)); + }).catch(() => { + setChallengingGameAssoc(null); + setWaitingCountdown(0); + }); }; const visible = players @@ -147,7 +164,12 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => { } return ( - + 0 ? undefined : onClose} + disableEscapeKeyDown={waitingCountdown > 0} + sx={DIALOG_SX} + >
@@ -160,32 +182,44 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
-
-
- - setSearch(e.target.value)} - /> - {search && ( - - )} -
+ {0 < waitingCountdown ? ( +
+ +

Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...

+
+ ) : ( +
+ + setSearch(e.target.value)} + /> + {search && ( + + )} +
+ )}
{loading && (
diff --git a/assets/js/mine-seeker/components/index.js b/assets/js/mine-seeker/components/index.js index 06c59b5..6e8c095 100644 --- a/assets/js/mine-seeker/components/index.js +++ b/assets/js/mine-seeker/components/index.js @@ -10,6 +10,7 @@ export { GameBoard } from './GameBoard'; export { default as OnlinePlayersDialog } from './OnlinePlayersDialog'; export { default as WaitingOverlayContent } from './WaitingOverlayContent'; +export { default as ChallengeCountdown } from './ChallengeCountdown'; export { default as GameTimer } from './GameTimer'; export { default as GridControl } from './grid/GridControl'; export { default as GridField } from './grid/GridField'; diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx index cd35913..f94d9f7 100644 --- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -14,7 +14,8 @@ import { DESC } from '@mine-utils'; import useStepTimer from './useStepTimer'; import { WaitingOverlayContent } from '@mine-components'; -/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */ +import { ChallengeCountdown } from '@mine-components'; + const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const { /** Async-safe refs */ @@ -136,8 +137,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const wChallenge = payload => { const { challengerName, challengerGameAssoc } = payload; + let declineTimeout = null; const handleAccept = () => { + clearTimeout(declineTimeout); fetch('/api/game/challenge/respond/' + challengerGameAssoc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -148,6 +151,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { }; const handleDecline = () => { + clearTimeout(declineTimeout); fetch('/api/game/challenge/respond/' + challengerGameAssoc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -162,12 +166,11 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { }).catch(() => {}); }; + declineTimeout = setTimeout(handleDecline, 30000); + showOverlay( challengerName + ' wants to challenge you!', -
- Accept - Decline -
, + , ); };