@@ -26,7 +26,7 @@ const MapWidget = dynamic(() => import("../components/Map"), { ssr: false });
2626// import RoundOverScreen from "./roundOverScreen";
2727const RoundOverScreen = dynamic ( ( ) => import ( "./roundOverScreen" ) , { ssr : false } ) ;
2828
29- export default function GameUI ( { inCoolMathGames, inGameDistribution, miniMapShown, setMiniMapShown, singlePlayerRound, setSinglePlayerRound, showDiscordModal, setShowDiscordModal, inCrazyGames, showPanoOnResult, setShowPanoOnResult, countryGuesserCorrect, setCountryGuesserCorrect, otherOptions, onboarding, setOnboarding, countryGuesser, options, timeOffset, ws, multiplayerState, backBtnPressed, setMultiplayerState, countryStreak, setCountryStreak, loading, setLoading, session, gameOptionsModalShown, setGameOptionsModalShown, latLong, loadLocation, gameOptions, setGameOptions, showAnswer, setShowAnswer, pinPoint, setPinPoint, hintShown, setHintShown, showCountryButtons, setShowCountryButtons } ) {
29+ export default function GameUI ( { inCoolMathGames, inGameDistribution, miniMapShown, setMiniMapShown, singlePlayerRound, setSinglePlayerRound, showDiscordModal, setShowDiscordModal, inCrazyGames, showPanoOnResult, setShowPanoOnResult, countryGuesserCorrect, setCountryGuesserCorrect, otherOptions, onboarding, setOnboarding, countryGuesser, options, timeOffset, ws, multiplayerState, backBtnPressed, setMultiplayerState, countryStreak, setCountryStreak, loading, setLoading, session, gameOptionsModalShown, setGameOptionsModalShown, mapModal , latLong, loadLocation, gameOptions, setGameOptions, showAnswer, setShowAnswer, pinPoint, setPinPoint, hintShown, setHintShown, showCountryButtons, setShowCountryButtons } ) {
3030 const { t : text } = useTranslation ( "common" ) ;
3131 const [ showStreakAdBanner , setShowStreakAdBanner ] = useState ( false ) ;
3232
@@ -194,6 +194,7 @@ export default function GameUI({ inCoolMathGames, inGameDistribution, miniMapSho
194194 const [ lostCountryStreak , setLostCountryStreak ] = useState ( 0 ) ;
195195 const [ timeToNextMultiplayerEvt , setTimeToNextMultiplayerEvt ] = useState ( 0 ) ;
196196 const [ timeToNextRound , setTimeToNextRound ] = useState ( 0 ) ; //only for onboarding
197+ const [ singlePlayerTimeLeft , setSinglePlayerTimeLeft ] = useState ( 0 ) ;
197198 const [ mapPinned , setMapPinned ] = useState ( false ) ;
198199 // dist between guess & target
199200 const [ km , setKm ] = useState ( null ) ;
@@ -276,6 +277,78 @@ export default function GameUI({ inCoolMathGames, inGameDistribution, miniMapSho
276277 }
277278 } , [ onboarding ?. nextRoundTime ] )
278279
280+ // Singleplayer countdown timer
281+ const singlePlayerTimerRef = useRef ( null ) ;
282+ const pinPointRef = useRef ( pinPoint ) ;
283+ pinPointRef . current = pinPoint ;
284+ const modalWasOpenRef = useRef ( false ) ;
285+ const wasLoadingRef = useRef ( loading ) ;
286+
287+ useEffect ( ( ) => {
288+ if ( singlePlayerTimerRef . current ) {
289+ clearInterval ( singlePlayerTimerRef . current ) ;
290+ singlePlayerTimerRef . current = null ;
291+ }
292+
293+ const modalOpen = gameOptionsModalShown || mapModal ;
294+
295+ if ( ! singlePlayerRound || singlePlayerRound . done || ! gameOptions . timePerRound || showAnswer || loading || ! roundStartTime || modalOpen ) {
296+ setSinglePlayerTimeLeft ( 0 ) ;
297+ if ( modalOpen ) modalWasOpenRef . current = true ;
298+ if ( loading ) wasLoadingRef . current = true ;
299+ return ;
300+ }
301+
302+ // Reset timer when returning from a modal or when loading just finished
303+ if ( modalWasOpenRef . current || wasLoadingRef . current ) {
304+ modalWasOpenRef . current = false ;
305+ wasLoadingRef . current = false ;
306+ setRoundStartTime ( Date . now ( ) ) ;
307+ return ;
308+ }
309+
310+ const deadline = roundStartTime + gameOptions . timePerRound * 1000 ;
311+ singlePlayerTimerRef . current = setInterval ( ( ) => {
312+ const remaining = Math . max ( 0 , Math . floor ( ( deadline - Date . now ( ) ) / 100 ) / 10 ) ;
313+ setSinglePlayerTimeLeft ( remaining ) ;
314+
315+ if ( remaining <= 0 ) {
316+ clearInterval ( singlePlayerTimerRef . current ) ;
317+ singlePlayerTimerRef . current = null ;
318+ if ( pinPointRef . current ) {
319+ // Player placed a pin — submit their guess normally
320+ document . querySelector ( '.guessBtn' ) ?. click ( ) ;
321+ } else {
322+ // No pin placed — score 0 points and show answer
323+ setShowAnswer ( true ) ;
324+ setCountryStreak ( 0 ) ;
325+ setSinglePlayerRound ( ( prev ) => {
326+ if ( ! prev ) return prev ;
327+ return {
328+ ...prev ,
329+ locations : [ ...prev . locations , {
330+ lat : latLong . lat , long : latLong . long ,
331+ panoId : latLong . panoId || null ,
332+ guessLat : null , guessLong : null ,
333+ points : 0 ,
334+ timeTaken : gameOptions . timePerRound ,
335+ xpEarned : 0
336+ } ] ,
337+ lastPoint : 0
338+ } ;
339+ } ) ;
340+ }
341+ }
342+ } , 100 ) ;
343+
344+ return ( ) => {
345+ if ( singlePlayerTimerRef . current ) {
346+ clearInterval ( singlePlayerTimerRef . current ) ;
347+ singlePlayerTimerRef . current = null ;
348+ }
349+ } ;
350+ } , [ roundStartTime , singlePlayerRound ?. done , gameOptions . timePerRound , showAnswer , loading , gameOptionsModalShown , mapModal ] )
351+
279352 useEffect ( ( ) => {
280353 if ( multiplayerState ?. inGame ) return ;
281354 if ( ! latLong ) {
@@ -766,34 +839,38 @@ session={session}/>
766839 }
767840 } } />
768841 ) }
769- < span className = { `timer ${ multiplayerState ?. gameData ?. duel && multiplayerState ?. gameData ?. public ? 'duel' : '' } ${ ! multiplayerTimerShown ? '' : 'shown' } ${ timeToNextMultiplayerEvt <= 5 && timeToNextMultiplayerEvt > 0 && ! showAnswer && ! pinPoint && multiplayerState ?. gameData ?. state === 'guess' ? 'critical' : '' } ` } >
770-
771- { /* Round #{multiplayerState?.gameData?.curRound} / {multiplayerState?.gameData?.rounds} - {timeToNextMultiplayerEvt}s */ }
772- {
773- multiplayerState ?. gameData ?. timePerRound === 86400000 &&
774- timeToNextMultiplayerEvt > 120
775- ?
776- text ( "round" , { r :multiplayerState ?. gameData ?. curRound , mr : multiplayerState ?. gameData ?. rounds } )
777-
778- :
779-
780- text ( "roundTimer" , { r :multiplayerState ?. gameData ?. curRound , mr : multiplayerState ?. gameData ?. rounds , t : timeToNextMultiplayerEvt . toFixed ( 1 ) } ) }
842+ < span className = { `timer timer--two-line ${ multiplayerState ?. gameData ?. duel && multiplayerState ?. gameData ?. public ? 'duel' : '' } ${ ! multiplayerTimerShown ? '' : 'shown' } ${ timeToNextMultiplayerEvt <= 5 && timeToNextMultiplayerEvt > 0 && ! showAnswer && ! pinPoint && multiplayerState ?. gameData ?. state === 'guess' ? 'critical' : '' } ` } >
843+ < span className = "timer__round-label" > { text ( "round" , { r :multiplayerState ?. gameData ?. curRound , mr : multiplayerState ?. gameData ?. rounds } ) } </ span >
844+ < span className = "timer__main-row" >
845+ { ! ( multiplayerState ?. gameData ?. timePerRound === 86400000 && timeToNextMultiplayerEvt > 120 )
846+ ? < > < span className = "timer__countdown" > { timeToNextMultiplayerEvt . toFixed ( 1 ) } s</ span > </ >
847+ : null
848+ }
781849 </ span >
782-
783- < span className = { `timer ${ ! onboardingTimerShown ? '' : 'shown' } ${ timeToNextRound <= 5 && timeToNextRound > 0 && ! showAnswer && ! pinPoint && onboarding ? 'critical' : '' } ` } >
784-
785- { /* Round #{multiplayerState?.gameData?.curRound} / {multiplayerState?.gameData?.rounds} - {timeToNextMultiplayerEvt}s */ }
786- { timeToNextRound ?
787- text ( "roundTimer" , { r :onboarding ?. round , mr : 5 , t : timeToNextRound . toFixed ( 1 ) } )
788- : text ( "round" , { r :onboarding ?. round , mr : 5 } ) } - < AnimatedCounter value = { onboarding ?. points || 0 } showIncrement = { false } /> { text ( "points" ) }
789-
850+ </ span >
851+
852+ < span className = { `timer timer--two-line ${ ! onboardingTimerShown ? '' : 'shown' } ${ timeToNextRound <= 5 && timeToNextRound > 0 && ! showAnswer && ! pinPoint && onboarding ? 'critical' : '' } ` } >
853+ < span className = "timer__round-label" > { text ( "round" , { r :onboarding ?. round , mr : 5 } ) } </ span >
854+ < span className = "timer__main-row" >
855+ { timeToNextRound
856+ ? < > < span className = "timer__countdown" > { timeToNextRound . toFixed ( 1 ) } s</ span > · </ >
857+ : null
858+ }
859+ < AnimatedCounter value = { onboarding ?. points || 0 } showIncrement = { false } /> { text ( "points" ) }
790860 </ span >
861+ </ span >
791862
792863 {
793864 singlePlayerRound && ! singlePlayerRound ?. done && (
794- < span className = "timer shown" >
795- { text ( "round" , { r : singlePlayerRound . round , mr : singlePlayerRound . totalRounds } ) } - < AnimatedCounter value = { singlePlayerRound . locations . reduce ( ( acc , cur ) => acc + cur . points , 0 ) } showIncrement = { false } /> { text ( "points" ) }
796-
865+ < span className = { `timer timer--two-line shown ${ singlePlayerTimeLeft <= 5 && singlePlayerTimeLeft > 0 && gameOptions . timePerRound > 0 && ! showAnswer && ! pinPoint ? 'critical' : '' } ` } >
866+ < span className = "timer__round-label" > { text ( "round" , { r : singlePlayerRound . round , mr : singlePlayerRound . totalRounds } ) } </ span >
867+ < span className = "timer__main-row" >
868+ { gameOptions . timePerRound > 0 && ! showAnswer && singlePlayerTimeLeft > 0
869+ ? < > < span className = "timer__countdown" > { singlePlayerTimeLeft . toFixed ( 1 ) } s</ span > · </ >
870+ : null
871+ }
872+ < AnimatedCounter value = { singlePlayerRound . locations . reduce ( ( acc , cur ) => acc + cur . points , 0 ) } showIncrement = { false } /> { text ( "points" ) }
873+ </ span >
797874 </ span >
798875 )
799876 }
0 commit comments