feat: username and point

This commit is contained in:
tiennm99 committed 2025-08-03 14:15:37 +07:00
1 parent 0eb450166a
commit 7b6e71dfbb
10 files changed
+452 -31

No files matched your search

+39 -4
View File
@@ -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
+72
View File
@@ -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
View File
@@ -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}
/>
+73
View File
@@ -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
View File
@@ -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>
);
}
+19 -1
View File
@@ -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) => {
+92 -11
View File
@@ -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>
+44
View File
@@ -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>
);
}
+2 -1
View File
@@ -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';
+16
View File
@@ -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;
}