diff --git a/CLAUDE.md b/CLAUDE.md
index 8c6954a..2920e89 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/src/app/api/scoring/route.js b/src/app/api/scoring/route.js
new file mode 100644
index 0000000..9664281
--- /dev/null
+++ b/src/app/api/scoring/route.js
@@ -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 }
+ );
+}
\ No newline at end of file
diff --git a/src/app/game/page.js b/src/app/game/page.js
index b670222..25bb876 100644
--- a/src/app/game/page.js
+++ b/src/app/game/page.js
@@ -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() {
{getResultMessage(distance)}
+{getResultMessage(distance, points)}
++ You can now explore around the actual location using the street view! +
+ diff --git a/src/components/ui/UsernameModal.js b/src/components/ui/UsernameModal.js new file mode 100644 index 0000000..cd3f069 --- /dev/null +++ b/src/components/ui/UsernameModal.js @@ -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 ( +Enter your username to start playing
+ + +