Skip to content

Commit 7b65143

Browse files
committed
Add singleplayer timer feature
1 parent f9818bf commit 7b65143

File tree

8 files changed

+195
-28
lines changed

8 files changed

+195
-28
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"Bash(done)",
1616
"Bash(find:*)",
1717
"Bash(cat:*)",
18-
"mcp__ide__getDiagnostics"
18+
"mcp__ide__getDiagnostics",
19+
"Bash(npx next build:*)"
1920
],
2021
"deny": [
2122
"Bash(git push:*)"

components/gameUI.js

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const MapWidget = dynamic(() => import("../components/Map"), { ssr: false });
2626
// import RoundOverScreen from "./roundOverScreen";
2727
const 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> &middot; </>
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> &middot; </>
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
}

components/home.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export default function Home({ }) {
9797
const [latLongKey, setLatLongKey] = useState(0) // Increment to force refresh even with same coords
9898
const [gameOptionsModalShown, setGameOptionsModalShown] = useState(false);
9999
// location aka map slug
100-
const [gameOptions, setGameOptions] = useState({ location: "all", maxDist: 20000, official: true, countryMap: false, communityMapName: "", extent: null, showRoadName: true }) // rate limit fix: showRoadName true
100+
const [gameOptions, setGameOptions] = useState({ location: "all", maxDist: 20000, official: true, countryMap: false, communityMapName: "", extent: null, showRoadName: true, timePerRound: 0 }) // rate limit fix: showRoadName true
101101
const [showAnswer, setShowAnswer] = useState(false)
102102

103103
const [pinPoint, setPinPoint] = useState(null)
@@ -2938,6 +2938,7 @@ export default function Home({ }) {
29382938
} : null}
29392939
showAllCountriesOption={(gameOptionsModalShown && screen === "singleplayer")}
29402940
showOptions={screen === "singleplayer"}
2941+
showTimerOption={screen === "singleplayer"}
29412942
gameOptions={gameOptions} setGameOptions={setGameOptions} />}
29422943

29432944
{settingsModal && <SettingsModal inCrazyGames={inCrazyGames} options={options} setOptions={setOptions} shown={true} onClose={() => setSettingsModal(false)} />}
@@ -2963,7 +2964,7 @@ export default function Home({ }) {
29632964
inCoolMathGames={inCoolMathGames}
29642965
inGameDistribution={inGameDistribution}
29652966
miniMapShown={miniMapShown} setMiniMapShown={setMiniMapShown}
2966-
singlePlayerRound={singlePlayerRound} setSinglePlayerRound={setSinglePlayerRound} showDiscordModal={showDiscordModal} setShowDiscordModal={setShowDiscordModal} inCrazyGames={inCrazyGames} showPanoOnResult={showPanoOnResult} setShowPanoOnResult={setShowPanoOnResult} options={options} countryStreak={countryStreak} setCountryStreak={setCountryStreak} hintShown={hintShown} setHintShown={setHintShown} pinPoint={pinPoint} setPinPoint={setPinPoint} showAnswer={showAnswer} setShowAnswer={setShowAnswer} loading={loading} setLoading={setLoading} session={session} gameOptionsModalShown={gameOptionsModalShown} setGameOptionsModalShown={setGameOptionsModalShown} latLong={latLong} loadLocation={loadLocation} gameOptions={gameOptions} setGameOptions={setGameOptions} />
2967+
singlePlayerRound={singlePlayerRound} setSinglePlayerRound={setSinglePlayerRound} showDiscordModal={showDiscordModal} setShowDiscordModal={setShowDiscordModal} inCrazyGames={inCrazyGames} showPanoOnResult={showPanoOnResult} setShowPanoOnResult={setShowPanoOnResult} options={options} countryStreak={countryStreak} setCountryStreak={setCountryStreak} hintShown={hintShown} setHintShown={setHintShown} pinPoint={pinPoint} setPinPoint={setPinPoint} showAnswer={showAnswer} setShowAnswer={setShowAnswer} loading={loading} setLoading={setLoading} session={session} gameOptionsModalShown={gameOptionsModalShown} setGameOptionsModalShown={setGameOptionsModalShown} mapModal={mapModal} latLong={latLong} loadLocation={loadLocation} gameOptions={gameOptions} setGameOptions={setGameOptions} />
29672968
</div>}
29682969

29692970
{screen === "onboarding" && (onboarding?.round || onboarding?.completed) && <div className="home__onboarding">

components/maps/mapView.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default function MapView({
1313
mapModalClosing,
1414
setGameOptions,
1515
showOptions,
16+
showTimerOption,
1617
close,
1718
session,
1819
text,
@@ -413,6 +414,30 @@ export default function MapView({
413414
}} />
414415
</div>
415416

417+
{showTimerOption && (
418+
<div className="map-option-timer">
419+
<label htmlFor="enableTimer">{text('enableTimer')}&nbsp;</label>
420+
<input id="enableTimer"
421+
name="enableTimer"
422+
type="checkbox" checked={gameOptions.timePerRound > 0} onChange={(e) => {
423+
setGameOptions({ ...gameOptions, timePerRound: e.target.checked ? 30 : 0 })
424+
}} />
425+
{gameOptions.timePerRound > 0 && (
426+
<div className="timer-slider">
427+
<input
428+
type="range"
429+
min="10"
430+
max="300"
431+
step="10"
432+
value={gameOptions.timePerRound}
433+
onChange={(e) => setGameOptions({ ...gameOptions, timePerRound: parseInt(e.target.value) })}
434+
/>
435+
<span className="timer-slider-value">{gameOptions.timePerRound}s</span>
436+
</div>
437+
)}
438+
</div>
439+
)}
440+
416441
</div>
417442
)}
418443

components/maps/mapsModal.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const initMakeMap = {
1616
edit: false,
1717
mapId: "",
1818
};
19-
export default function MapsModal({ gameOptions, mapModalClosing, setGameOptions, shown, onClose, session, text, customChooseMapCallback, chosenMap, showAllCountriesOption, showOptions }) {
19+
export default function MapsModal({ gameOptions, mapModalClosing, setGameOptions, shown, onClose, session, text, customChooseMapCallback, chosenMap, showAllCountriesOption, showOptions, showTimerOption }) {
2020
const [makeMap, setMakeMap] = useState(initMakeMap);
2121
const [searchTerm, setSearchTerm] = useState("");
2222
const [searchResults, setSearchResults] = useState([]);
@@ -96,6 +96,7 @@ export default function MapsModal({ gameOptions, mapModalClosing, setGameOptions
9696
<MapView
9797
mapModalClosing={mapModalClosing}
9898
showOptions={showOptions}
99+
showTimerOption={showTimerOption}
99100
showAllCountriesOption={showAllCountriesOption}
100101
chosenMap={chosenMap}
101102
close={onClose}

public/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"nm": "No moving",
9595
"npz": "No panning or zooming",
9696
"nmpz": "No moving, panning or zooming",
97+
"enableTimer": "Timer",
9798
"showRoadName": "Show Road Labels",
9899
"settings": "Settings",
99100
"units": "Units",

styles/globals.scss

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,6 +1184,7 @@ h1, h2, h3, span, label {
11841184
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
11851185
margin-right: 10px;
11861186
letter-spacing: 0.025em;
1187+
font-variant-numeric: tabular-nums;
11871188
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
11881189
}
11891190

@@ -1194,6 +1195,30 @@ h1, h2, h3, span, label {
11941195
z-index: 1001;
11951196
}
11961197

1198+
.timer--two-line {
1199+
display: flex;
1200+
flex-direction: column;
1201+
align-items: center;
1202+
text-align: center;
1203+
gap: 2px;
1204+
padding: 8px 20px 12px;
1205+
}
1206+
1207+
.timer__round-label {
1208+
font-size: 0.7em;
1209+
opacity: 0.75;
1210+
letter-spacing: 0.05em;
1211+
}
1212+
1213+
.timer__main-row {
1214+
font-size: 1.1em;
1215+
}
1216+
1217+
.timer__countdown {
1218+
display: inline-block;
1219+
text-align: center;
1220+
}
1221+
11971222
.timer.duel {
11981223
right: 50%;
11991224
transform: translateX(50%);

styles/mapModal.css

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
/* Game Options */
186186
.map-options {
187187
display: flex;
188+
flex-wrap: wrap;
188189
justify-content: center;
189190
gap: 24px;
190191
margin-bottom: 32px;
@@ -211,6 +212,41 @@
211212
accent-color: var(--map-primary);
212213
}
213214

215+
.map-option-timer {
216+
display: flex;
217+
align-items: center;
218+
gap: 8px;
219+
color: white;
220+
font-size: 1rem;
221+
flex-basis: 100%;
222+
justify-content: center;
223+
}
224+
225+
.map-option-timer input[type="checkbox"] {
226+
width: 18px;
227+
height: 18px;
228+
accent-color: var(--map-primary);
229+
}
230+
231+
.timer-slider {
232+
display: flex;
233+
align-items: center;
234+
gap: 8px;
235+
}
236+
237+
.timer-slider input[type="range"] {
238+
width: 120px;
239+
accent-color: var(--map-primary);
240+
cursor: pointer;
241+
}
242+
243+
.timer-slider-value {
244+
min-width: 40px;
245+
text-align: center;
246+
font-weight: 600;
247+
color: white;
248+
}
249+
214250
/* All Countries Option */
215251
.all-countries-tile {
216252
display: flex;

0 commit comments

Comments
 (0)