From 7b6e71dfbb868b88d41bb4c6302e8a17931217ba Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 3 Aug 2025 14:15:37 +0700 Subject: [PATCH] feat: username and point --- CLAUDE.md | 43 ++++++++++- src/app/api/scoring/route.js | 72 +++++++++++++++++ src/app/game/page.js | 78 ++++++++++++++++--- src/app/globals.css | 73 ++++++++++++++++++ src/app/page.js | 31 +++++++- src/components/features/PanoViewer.js | 20 ++++- src/components/features/ResultModal.js | 103 ++++++++++++++++++++++--- src/components/ui/UsernameModal.js | 44 +++++++++++ src/components/ui/index.js | 3 +- src/utils/distance.js | 16 ++++ 10 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 src/app/api/scoring/route.js create mode 100644 src/components/ui/UsernameModal.js 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() { diff --git a/src/app/globals.css b/src/app/globals.css index c8f1dc0..8ab7598 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/page.js b/src/app/page.js index b3ae762..0085b64 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -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)} /> + + ); } diff --git a/src/components/features/PanoViewer.js b/src/components/features/PanoViewer.js index d53a9e0..154b968 100644 --- a/src/components/features/PanoViewer.js +++ b/src/components/features/PanoViewer.js @@ -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) => { diff --git a/src/components/features/ResultModal.js b/src/components/features/ResultModal.js index 59a9247..78e827e 100644 --- a/src/components/features/ResultModal.js +++ b/src/components/features/ResultModal.js @@ -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 (
-
+
RESULT
- {formatDistance(distance)} -
-

{getResultMessage(distance)}

+
+ {formatDistance(distance)} +
+ {points} POINTS +
+
+ +
+
+
+
+ +

{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 ( +
+
+

Welcome to VIET NAM GUESSR!

+

Enter your username to start playing

+ +
+ setUsername(e.target.value)} + placeholder="Your username" + className="username-input" + maxLength={20} + autoFocus + /> + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/index.js b/src/components/ui/index.js index da083c3..cc2f777 100644 --- a/src/components/ui/index.js +++ b/src/components/ui/index.js @@ -1,2 +1,3 @@ export { default as Header } from './Header'; -export { default as DonateModal } from './DonateModal'; \ No newline at end of file +export { default as DonateModal } from './DonateModal'; +export { default as UsernameModal } from './UsernameModal'; \ No newline at end of file diff --git a/src/utils/distance.js b/src/utils/distance.js index 87da068..43e1fa2 100644 --- a/src/utils/distance.js +++ b/src/utils/distance.js @@ -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; } \ No newline at end of file