mirror of
https://github.com/tiennm99/vigeoguessr.git
synced 2026-10-15 12:15:08 +00:00
feat: username and point
This commit is contained in:
1 parent
0eb450166a
commit
7b6e71dfbb
10 files changed
+452
-31
No files matched your search
@@ -56,10 +56,34 @@ src/
|
||||
This project uses a carefully chosen combination of specialized libraries rather than trying to force a single library to handle all use cases. This approach provides superior user experience, maintainability, and performance.
|
||||
|
||||
### Core Game Flow
|
||||
1. **Image Fetching**: `src/utils/mapillary.js:getRandomMapillaryImage()` fetches random panoramic images via server-side API (`src/app/api/mapillary/route.js`) within predefined Vietnamese city boundaries
|
||||
2. **Display**: `src/components/features/PanoViewer.js` renders 360° images using MapillaryJS npm module with fallback to regular images
|
||||
3. **Interaction**: `src/components/features/GameMap.js` provides MapLibre GL JS-based interactive map for location guessing
|
||||
4. **Scoring**: Distance calculation using haversine formula in `src/utils/distance.js:calculateDistance()`
|
||||
|
||||
**Multi-User Gameplay Experience:**
|
||||
1. **User Entry**: Home page checks localStorage for cached username, displays popup if not found
|
||||
2. **City Selection**: User selects from available Vietnamese cities on home page
|
||||
3. **Game Initialization**: Navigate to game scene with selected city parameter
|
||||
4. **Image Loading**: Server provides random panoramic image from city bounds via `src/app/api/mapillary/route.js`
|
||||
5. **Gameplay**:
|
||||
- Display raw panoramic image without location metadata
|
||||
- Show city overview map for location guessing
|
||||
- User submits guess location with username
|
||||
6. **Scoring System**: Server calculates distance-based points (0-5 scale) per user session:
|
||||
- 0-50m = 5 points
|
||||
- 50m-100m = 4 points
|
||||
- 100m-200m = 3 points
|
||||
- 200m-500m = 2 points
|
||||
- 500m-1km = 1 point
|
||||
- >1km = 0 points
|
||||
7. **Results**:
|
||||
- Show guess location, actual location, distance, and points
|
||||
- Enable MapillaryJS navigation around actual location
|
||||
- "Next" button continues to next round
|
||||
|
||||
**Technical Implementation:**
|
||||
- **Image Fetching**: `src/utils/mapillary.js:getRandomMapillaryImage()` fetches random panoramic images via server-side API
|
||||
- **Display**: `src/components/features/PanoViewer.js` renders raw 360° images using MapillaryJS npm module
|
||||
- **Interaction**: `src/components/features/GameMap.js` provides MapLibre GL JS-based interactive map for location guessing
|
||||
- **Scoring**: Enhanced distance calculation with point system in `src/utils/distance.js:calculateDistance()`
|
||||
- **Session Management**: Per-user session tracking for concurrent multi-player support
|
||||
|
||||
### Key Components
|
||||
- **GameMap**: MapLibre GL JS integration with click-based location selection, automatic centering based on city choice, and native coordinate synchronization
|
||||
@@ -146,6 +170,17 @@ Game state managed at page level with React hooks:
|
||||
- Image fallback system handles panoramic vs regular image display
|
||||
|
||||
### Recent Changes (Completed)
|
||||
|
||||
**Multi-User Gameplay Implementation (Latest):**
|
||||
- **Username System**: Added localStorage-cached username with popup modal for first-time users
|
||||
- **City Selection Interface**: Enhanced home page with username-aware city selection
|
||||
- **Per-User Scoring API**: Created `/api/scoring` endpoint with session tracking for concurrent multi-player support
|
||||
- **Distance-Based Point System**: Implemented 0-5 point scale based on accuracy (0-50m=5pts, 50m-100m=4pts, 100m-200m=3pts, 200m-500m=2pts, 500m-1km=1pt, >1km=0pts)
|
||||
- **Raw Image Display**: Modified PanoViewer to disable navigation controls during gameplay, preventing location information exposure
|
||||
- **Enhanced Result Modal**: Added points display, side-by-side map/street view layout, and enabled MapillaryJS navigation in result phase
|
||||
- **Game Flow Integration**: Updated game page to handle username parameters, server-side scoring, and fallback error handling
|
||||
|
||||
### Recent Changes (Previous)
|
||||
- **Structure**: Migrated to standard Next.js `src/` directory structure
|
||||
- **Code Organization**: Separated utilities (`src/utils/`), constants (`src/constants/`), UI components (`src/components/ui/`), and feature components (`src/components/features/`)
|
||||
- **Import Optimization**: Updated all import paths and added index files for cleaner imports
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { calculateDistance } from '@/utils/distance';
|
||||
|
||||
const userSessions = new Map();
|
||||
|
||||
function calculatePoints(distanceKm) {
|
||||
if (distanceKm > 1) return 0;
|
||||
if (distanceKm > 0.5) return 1;
|
||||
if (distanceKm > 0.2) return 2;
|
||||
if (distanceKm > 0.1) return 3;
|
||||
if (distanceKm > 0.05) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { username, guessLat, guessLng, trueLat, trueLng, imageId } = await request.json();
|
||||
|
||||
if (!username || !guessLat || !guessLng || !trueLat || !trueLng || !imageId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required parameters' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = `${username}_${Date.now()}`;
|
||||
const distance = calculateDistance(guessLat, guessLng, trueLat, trueLng);
|
||||
const points = calculatePoints(distance);
|
||||
|
||||
const sessionData = {
|
||||
username,
|
||||
imageId,
|
||||
guessLocation: [guessLat, guessLng],
|
||||
trueLocation: [trueLat, trueLng],
|
||||
distance,
|
||||
points,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
userSessions.set(sessionId, sessionData);
|
||||
|
||||
console.log(`Score calculated for ${username}: ${distance.toFixed(2)}km = ${points} points`);
|
||||
|
||||
return NextResponse.json({
|
||||
sessionId,
|
||||
distance,
|
||||
points,
|
||||
username
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error calculating score:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to calculate score' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sessionId = searchParams.get('sessionId');
|
||||
|
||||
if (sessionId && userSessions.has(sessionId)) {
|
||||
return NextResponse.json(userSessions.get(sessionId));
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
+66
-12
@@ -8,7 +8,7 @@ import PanoViewer from '@/components/features/PanoViewer';
|
||||
import GameMap from '@/components/features/GameMap';
|
||||
import ResultModal from '@/components/features/ResultModal';
|
||||
import { getRandomMapillaryImage } from '@/utils/mapillary';
|
||||
import { calculateDistance } from '@/utils/distance';
|
||||
import { calculateDistance, calculatePoints } from '@/utils/distance';
|
||||
|
||||
function GamePageContent() {
|
||||
const router = useRouter();
|
||||
@@ -18,9 +18,11 @@ function GamePageContent() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [imageData, setImageData] = useState(null);
|
||||
const [choiceLocation, setChoiceLocation] = useState('HN');
|
||||
const [username, setUsername] = useState('');
|
||||
const [trueLocation, setTrueLocation] = useState([0, 0]);
|
||||
const [guessLocation, setGuessLocation] = useState([0, 0]);
|
||||
const [distance, setDistance] = useState(0);
|
||||
const [points, setPoints] = useState(0);
|
||||
const [hasGuessed, setHasGuessed] = useState(false);
|
||||
|
||||
const loadRandomImage = useCallback(async () => {
|
||||
@@ -42,10 +44,23 @@ function GamePageContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const location = searchParams.get('location');
|
||||
const usernameParam = searchParams.get('username');
|
||||
|
||||
if (location) {
|
||||
setChoiceLocation(location);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
if (usernameParam) {
|
||||
setUsername(decodeURIComponent(usernameParam));
|
||||
} else {
|
||||
const cachedUsername = localStorage.getItem('vigeoguessr_username');
|
||||
if (cachedUsername) {
|
||||
setUsername(cachedUsername);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRandomImage();
|
||||
@@ -62,24 +77,61 @@ function GamePageContent() {
|
||||
setTrueLocation([lat, lng]);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!hasGuessed) return;
|
||||
const handleSubmit = async () => {
|
||||
if (!hasGuessed || !username || !imageData) return;
|
||||
|
||||
const dist = calculateDistance(
|
||||
guessLocation[0],
|
||||
guessLocation[1],
|
||||
trueLocation[0],
|
||||
trueLocation[1]
|
||||
);
|
||||
try {
|
||||
const response = await fetch('/api/scoring', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
guessLat: guessLocation[0],
|
||||
guessLng: guessLocation[1],
|
||||
trueLat: trueLocation[0],
|
||||
trueLng: trueLocation[1],
|
||||
imageId: imageData.id
|
||||
})
|
||||
});
|
||||
|
||||
setDistance(dist);
|
||||
setShowResultModal(true);
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setDistance(result.distance);
|
||||
setPoints(result.points);
|
||||
setShowResultModal(true);
|
||||
} else {
|
||||
console.error('Failed to submit score');
|
||||
const fallbackDistance = calculateDistance(
|
||||
guessLocation[0],
|
||||
guessLocation[1],
|
||||
trueLocation[0],
|
||||
trueLocation[1]
|
||||
);
|
||||
setDistance(fallbackDistance);
|
||||
setPoints(calculatePoints(fallbackDistance));
|
||||
setShowResultModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error submitting score:', error);
|
||||
const fallbackDistance = calculateDistance(
|
||||
guessLocation[0],
|
||||
guessLocation[1],
|
||||
trueLocation[0],
|
||||
trueLocation[1]
|
||||
);
|
||||
setDistance(fallbackDistance);
|
||||
setPoints(calculatePoints(fallbackDistance));
|
||||
setShowResultModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextRound = () => {
|
||||
setShowResultModal(false);
|
||||
setHasGuessed(false);
|
||||
setDistance(0);
|
||||
setPoints(0);
|
||||
loadRandomImage();
|
||||
};
|
||||
|
||||
@@ -143,8 +195,10 @@ function GamePageContent() {
|
||||
<ResultModal
|
||||
isOpen={showResultModal}
|
||||
distance={distance}
|
||||
points={points}
|
||||
trueLocation={trueLocation}
|
||||
guessLocation={guessLocation}
|
||||
imageData={imageData}
|
||||
onNextRound={handleNextRound}
|
||||
/>
|
||||
|
||||
|
||||
@@ -313,6 +313,79 @@ body {
|
||||
color: #e6f4e6;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.username-modal {
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: 'Jersey 15', cursive;
|
||||
font-size: 24px;
|
||||
color: #05A38C;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-family: 'Jersey 15', cursive;
|
||||
font-size: 16px;
|
||||
color: #e6f4e6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.username-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #05A38C;
|
||||
border-radius: 8px;
|
||||
background-color: #1a1a1a;
|
||||
color: #e6f4e6;
|
||||
font-family: 'Jersey 15', cursive;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.username-input:focus {
|
||||
outline: none;
|
||||
border-color: #05A38C;
|
||||
box-shadow: 0 0 0 2px rgba(5, 163, 140, 0.3);
|
||||
}
|
||||
|
||||
.username-submit-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #05A38C;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-family: 'Jersey 15', cursive;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.username-submit-btn:hover:not(:disabled) {
|
||||
background-color: #048a76;
|
||||
}
|
||||
|
||||
.username-submit-btn:disabled {
|
||||
background-color: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.distance-text {
|
||||
font-family: 'Jersey 15', cursive;
|
||||
font-size: 32px;
|
||||
|
||||
+29
-2
@@ -1,17 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Header from '@/components/ui/Header';
|
||||
import DonateModal from '@/components/ui/DonateModal';
|
||||
import UsernameModal from '@/components/ui/UsernameModal';
|
||||
import { locationNames } from '@/constants/locations';
|
||||
|
||||
export default function Home() {
|
||||
const [showDonateModal, setShowDonateModal] = useState(false);
|
||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const cachedUsername = localStorage.getItem('vigeoguessr_username');
|
||||
if (cachedUsername) {
|
||||
setUsername(cachedUsername);
|
||||
} else {
|
||||
setShowUsernameModal(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsernameSubmit = (newUsername) => {
|
||||
setUsername(newUsername);
|
||||
localStorage.setItem('vigeoguessr_username', newUsername);
|
||||
setShowUsernameModal(false);
|
||||
};
|
||||
|
||||
const startGame = (location) => {
|
||||
router.push(`/game?location=${location}`);
|
||||
if (!username) {
|
||||
setShowUsernameModal(true);
|
||||
return;
|
||||
}
|
||||
router.push(`/game?location=${location}&username=${encodeURIComponent(username)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -63,6 +85,11 @@ export default function Home() {
|
||||
isOpen={showDonateModal}
|
||||
onClose={() => setShowDonateModal(false)}
|
||||
/>
|
||||
|
||||
<UsernameModal
|
||||
isOpen={showUsernameModal}
|
||||
onSubmit={handleUsernameSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,25 @@ export default function PanoViewer({ imageData, isLoading, onPreciseLocationLoad
|
||||
viewerRef.current = new Viewer({
|
||||
accessToken: process.env.NEXT_PUBLIC_MAPILLARY_ACCESS_TOKEN || 'MLY|24113623194974280|5bf83fa202912f1cc3210b2cf968fb65',
|
||||
container: panoRef.current,
|
||||
imageId: imageData.id
|
||||
imageId: imageData.id,
|
||||
component: {
|
||||
attribution: false,
|
||||
bearing: false,
|
||||
cache: false,
|
||||
cover: false,
|
||||
direction: false,
|
||||
image: true,
|
||||
keyboard: false,
|
||||
loading: true,
|
||||
marker: false,
|
||||
mouse: true,
|
||||
pointer: false,
|
||||
popup: false,
|
||||
sequence: false,
|
||||
spatial: false,
|
||||
tag: false,
|
||||
zoom: true
|
||||
}
|
||||
});
|
||||
|
||||
viewerRef.current.on('image', async (image) => {
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { Viewer } from 'mapillary-js';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
|
||||
export default function ResultModal({
|
||||
isOpen,
|
||||
distance,
|
||||
points,
|
||||
trueLocation,
|
||||
guessLocation,
|
||||
imageData,
|
||||
onNextRound
|
||||
}) {
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const panoRef = useRef(null);
|
||||
const viewerRef = useRef(null);
|
||||
const markersRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -155,6 +161,60 @@ export default function ResultModal({
|
||||
};
|
||||
}, [isOpen, trueLocation, guessLocation]);
|
||||
|
||||
// Initialize MapillaryJS viewer in result phase with navigation enabled
|
||||
useEffect(() => {
|
||||
if (!isOpen || !panoRef.current || !imageData) return;
|
||||
|
||||
const initResultViewer = async () => {
|
||||
try {
|
||||
if (viewerRef.current) {
|
||||
viewerRef.current.remove();
|
||||
}
|
||||
|
||||
viewerRef.current = new Viewer({
|
||||
accessToken: process.env.NEXT_PUBLIC_MAPILLARY_ACCESS_TOKEN || 'MLY|24113623194974280|5bf83fa202912f1cc3210b2cf968fb65',
|
||||
container: panoRef.current,
|
||||
imageId: imageData.id,
|
||||
component: {
|
||||
attribution: false,
|
||||
bearing: true,
|
||||
cache: false,
|
||||
cover: false,
|
||||
direction: true,
|
||||
image: true,
|
||||
keyboard: true,
|
||||
loading: true,
|
||||
marker: false,
|
||||
mouse: true,
|
||||
pointer: true,
|
||||
popup: false,
|
||||
sequence: true,
|
||||
spatial: false,
|
||||
tag: false,
|
||||
zoom: true
|
||||
}
|
||||
});
|
||||
|
||||
viewerRef.current.on('image', (image) => {
|
||||
console.log('Result phase MapillaryJS loaded with navigation:', image.id);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing result MapillaryJS viewer:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(initResultViewer, 200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (viewerRef.current) {
|
||||
viewerRef.current.remove();
|
||||
viewerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isOpen, imageData]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const formatDistance = (dist) => {
|
||||
@@ -164,22 +224,43 @@ export default function ResultModal({
|
||||
return `${dist} M`;
|
||||
};
|
||||
|
||||
const getResultMessage = (dist) => {
|
||||
if (dist > 1000) {
|
||||
return "Hmm! Nice try.";
|
||||
} else if (dist > 200) {
|
||||
return "Good job!";
|
||||
}
|
||||
return "Excellent!";
|
||||
const getResultMessage = (dist, pts) => {
|
||||
if (pts >= 5) return "Perfect! 🎯";
|
||||
if (pts >= 4) return "Excellent! 🎉";
|
||||
if (pts >= 3) return "Great job! 👍";
|
||||
if (pts >= 2) return "Good guess! 👌";
|
||||
if (pts >= 1) return "Nice try! 🎯";
|
||||
return "Keep practicing! 💪";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" style={{ display: 'block' }}>
|
||||
<div className="modal-content">
|
||||
<div className="modal-content" style={{ maxWidth: '90vw', width: '800px' }}>
|
||||
<div className="score-text">RESULT</div>
|
||||
<span className="distance-text">{formatDistance(distance)}</span>
|
||||
<div className="container" ref={mapRef} style={{ height: '300px' }}></div>
|
||||
<p>{getResultMessage(distance)}</p>
|
||||
<div style={{ display: 'flex', gap: '20px', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<span className="distance-text">{formatDistance(distance)}</span>
|
||||
<div style={{
|
||||
background: '#05A38C',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
fontFamily: 'Jersey 15, cursive',
|
||||
fontSize: '24px',
|
||||
color: 'white'
|
||||
}}>
|
||||
{points} POINTS
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '15px', height: '300px', marginBottom: '15px' }}>
|
||||
<div ref={mapRef} style={{ flex: 1, minHeight: '300px' }}></div>
|
||||
<div ref={panoRef} style={{ flex: 1, minHeight: '300px', borderRadius: '8px' }}></div>
|
||||
</div>
|
||||
|
||||
<p style={{ marginBottom: '15px' }}>{getResultMessage(distance, points)}</p>
|
||||
<p style={{ fontSize: '14px', color: '#ccc', marginBottom: '15px' }}>
|
||||
You can now explore around the actual location using the street view!
|
||||
</p>
|
||||
|
||||
<button onClick={onNextRound} className="next-btn">
|
||||
<div className="next-text">NEXT ROUND</div>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function UsernameModal({ isOpen, onSubmit }) {
|
||||
const [username, setUsername] = useState('');
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (username.trim()) {
|
||||
onSubmit(username.trim());
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content username-modal">
|
||||
<h2 className="modal-title">Welcome to VIET NAM GUESSR!</h2>
|
||||
<p className="modal-subtitle">Enter your username to start playing</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your username"
|
||||
className="username-input"
|
||||
maxLength={20}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="username-submit-btn"
|
||||
disabled={!username.trim()}
|
||||
>
|
||||
START PLAYING
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as DonateModal } from './DonateModal';
|
||||
export { default as DonateModal } from './DonateModal';
|
||||
export { default as UsernameModal } from './UsernameModal';
|
||||
@@ -29,4 +29,20 @@ export function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||
dist = dist * 1609.34; // Convert to meters
|
||||
|
||||
return parseFloat(dist.toFixed(1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates points based on distance (in meters)
|
||||
* @param {number} distanceMeters - Distance in meters
|
||||
* @returns {number} Points (1-5 scale)
|
||||
*/
|
||||
export function calculatePoints(distanceMeters) {
|
||||
const distanceKm = distanceMeters / 1000;
|
||||
|
||||
if (distanceKm > 1) return 0;
|
||||
if (distanceKm > 0.5) return 1;
|
||||
if (distanceKm > 0.2) return 2;
|
||||
if (distanceKm > 0.1) return 3;
|
||||
if (distanceKm > 0.05) return 4;
|
||||
return 5;
|
||||
}
|
||||
Reference in new issue
Block a user