Spaces:
Sleeping
Sleeping
import os | |
import json | |
import uuid | |
import math | |
import random | |
import base64 | |
import io | |
import requests | |
from fastapi import FastAPI, Request, Depends, HTTPException, status | |
from fastapi.responses import JSONResponse, HTMLResponse | |
from fastapi.staticfiles import StaticFiles | |
from fastapi.templating import Jinja2Templates | |
from fastapi.security import HTTPBasic, HTTPBasicCredentials | |
from dotenv import load_dotenv | |
from PIL import Image, ImageDraw, ImageFont | |
load_dotenv() | |
# Paths | |
BASE_DIR = os.path.dirname(__file__) | |
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") | |
STATIC_DIR = os.path.join(BASE_DIR, "static") | |
ZONES_FILE = os.path.join(BASE_DIR, "zones.json") | |
app = FastAPI() | |
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
templates = Jinja2Templates(directory=TEMPLATES_DIR) | |
# In-memory state | |
games = {} | |
zones = {"easy": [], "medium": [], "hard": []} | |
# --- Zone Persistence Functions --- | |
def save_zones_to_file() -> None: | |
try: | |
with open(ZONES_FILE, 'w') as f: | |
json.dump(zones, f, indent=4) | |
except Exception as e: | |
print(f"Error saving zones: {e}") | |
def load_zones_from_file() -> None: | |
global zones | |
if os.path.exists(ZONES_FILE): | |
try: | |
with open(ZONES_FILE, 'r') as f: | |
loaded_zones = json.load(f) | |
if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])): | |
raise ValueError("Invalid zones format") | |
migrated = False | |
for difficulty in loaded_zones: | |
for zone in loaded_zones[difficulty]: | |
if 'id' not in zone: | |
zone['id'] = uuid.uuid4().hex | |
migrated = True | |
zones = loaded_zones | |
if migrated: | |
save_zones_to_file() | |
except Exception as e: | |
print(f"Warning: '{ZONES_FILE}' is corrupted or invalid ({e}). Recreating with empty zones.") | |
save_zones_to_file() | |
else: | |
save_zones_to_file() | |
# Predefined fallback locations | |
LOCATIONS = [ | |
{'lat': 48.85824, 'lng': 2.2945}, # Eiffel Tower, Paris | |
{'lat': 40.748440, 'lng': -73.985664}, # Empire State Building, New York | |
{'lat': 35.689487, 'lng': 139.691711}, # Tokyo, Japan | |
{'lat': -33.856784, 'lng': 151.215297} # Sydney Opera House, Australia | |
] | |
def generate_game_id() -> str: | |
return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) | |
def draw_compass_on_image(image_data_base64: str, heading: int) -> str: | |
try: | |
img = Image.open(io.BytesIO(base64.b64decode(image_data_base64))) | |
img_with_compass = img.copy() | |
draw = ImageDraw.Draw(img_with_compass) | |
compass_size = 80 | |
margin = 20 | |
x = img.width - compass_size - margin | |
y = margin | |
center_x = x + compass_size // 2 | |
center_y = y + compass_size // 2 | |
draw.ellipse([x, y, x + compass_size, y + compass_size], fill=(240, 240, 240), outline=(249, 115, 22), width=3) | |
font_size = 12 | |
try: | |
font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size) | |
except Exception: | |
try: | |
font = ImageFont.truetype("arial.ttf", font_size) | |
except Exception: | |
font = ImageFont.load_default() | |
directions = [ | |
("N", center_x, y + 8, (220, 38, 38)), | |
("E", x + compass_size - 15, center_y, (249, 115, 22)), | |
("S", center_x, y + compass_size - 20, (249, 115, 22)), | |
("W", x + 8, center_y, (249, 115, 22)), | |
] | |
for text, text_x, text_y, color in directions: | |
bbox = draw.textbbox((0, 0), text, font=font) | |
text_width = bbox[2] - bbox[0] | |
text_height = bbox[3] - bbox[1] | |
circle_radius = 10 | |
draw.ellipse([text_x - circle_radius, text_y - circle_radius, text_x + circle_radius, text_y + circle_radius], fill=(255, 255, 255), outline=color, width=1) | |
draw.text((text_x - text_width // 2, text_y - text_height // 2), text, font=font, fill=color) | |
needle_length = compass_size // 2 - 15 | |
needle_angle = math.radians(heading) | |
end_x = center_x + needle_length * math.sin(needle_angle) | |
end_y = center_y - needle_length * math.cos(needle_angle) | |
draw.line([center_x, center_y, end_x, end_y], fill=(220, 38, 38), width=4) | |
tip_radius = 3 | |
draw.ellipse([end_x - tip_radius, end_y - tip_radius, end_x + tip_radius, end_y + tip_radius], fill=(220, 38, 38)) | |
center_radius = 4 | |
draw.ellipse([center_x - center_radius, center_y - center_radius, center_x + center_radius, center_y + center_radius], fill=(249, 115, 22)) | |
label_y = y + compass_size + 5 | |
label_text = f"{heading}°" | |
bbox = draw.textbbox((0, 0), label_text, font=font) | |
label_width = bbox[2] - bbox[0] | |
draw.text((center_x - label_width // 2, label_y), label_text, font=font, fill=(249, 115, 22)) | |
buffer = io.BytesIO() | |
img_with_compass.save(buffer, format='JPEG', quality=85) | |
return base64.b64encode(buffer.getvalue()).decode('utf-8') | |
except Exception as e: | |
print(f"Error drawing compass: {e}") | |
return image_data_base64 | |
# --- Auth --- | |
security = HTTPBasic() | |
def verify_basic_auth(credentials: HTTPBasicCredentials = Depends(security)) -> None: | |
admin_user = os.getenv('ADMIN_USERNAME', 'admin') | |
admin_pass = os.getenv('ADMIN_PASSWORD', 'password') | |
if not (credentials.username == admin_user and credentials.password == admin_pass): | |
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", headers={"WWW-Authenticate": "Basic"}) | |
def index(request: Request): | |
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') | |
if not google_maps_api_key: | |
return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500) | |
base_path = request.scope.get('root_path', '') | |
return templates.TemplateResponse("index.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path}) | |
def admin(request: Request, _: None = Depends(verify_basic_auth)): | |
google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') | |
if not google_maps_api_key: | |
return HTMLResponse("Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", status_code=500) | |
base_path = request.scope.get('root_path', '') | |
return templates.TemplateResponse("admin.html", {"request": request, "google_maps_api_key": google_maps_api_key, "base_path": base_path}) | |
def get_zones(): | |
return JSONResponse(zones) | |
def create_zone(payload: dict): | |
difficulty = payload.get('difficulty') | |
zone_data = payload.get('zone') | |
if difficulty and zone_data and difficulty in zones: | |
zone_data['id'] = uuid.uuid4().hex | |
zones[difficulty].append(zone_data) | |
save_zones_to_file() | |
return {"message": "Zone saved successfully"} | |
raise HTTPException(status_code=400, detail="Invalid data") | |
def delete_zone(payload: dict): | |
zone_id = payload.get('zone_id') | |
if not zone_id: | |
raise HTTPException(status_code=400, detail="Zone ID is required") | |
for difficulty in zones: | |
zones[difficulty] = [z for z in zones[difficulty] if z.get('id') != zone_id] | |
save_zones_to_file() | |
return {"message": "Zone deleted successfully"} | |
def direction_to_degree(direction: str): | |
directions = { | |
'N': 0, 'NORTH': 0, | |
'NE': 45, 'NORTHEAST': 45, | |
'E': 90, 'EAST': 90, | |
'SE': 135, 'SOUTHEAST': 135, | |
'S': 180, 'SOUTH': 180, | |
'SW': 225, 'SOUTHWEST': 225, | |
'W': 270, 'WEST': 270, | |
'NW': 315, 'NORTHWEST': 315 | |
} | |
return directions.get(direction.upper()) if isinstance(direction, str) else None | |
def calculate_new_location(current_lat: float, current_lng: float, degree: float, distance_km: float = 0.1): | |
lat_rad = math.radians(current_lat) | |
lng_rad = math.radians(current_lng) | |
bearing_rad = math.radians(degree) | |
R = 6371.0 | |
new_lat_rad = math.asin( | |
math.sin(lat_rad) * math.cos(distance_km / R) + | |
math.cos(lat_rad) * math.sin(distance_km / R) * math.cos(bearing_rad) | |
) | |
new_lng_rad = lng_rad + math.atan2( | |
math.sin(bearing_rad) * math.sin(distance_km / R) * math.cos(lat_rad), | |
math.cos(distance_km / R) - math.sin(lat_rad) * math.sin(new_lat_rad) | |
) | |
new_lat = math.degrees(new_lat_rad) | |
new_lng = math.degrees(new_lng_rad) | |
new_lng = ((new_lng + 180) % 360) - 180 | |
return new_lat, new_lng | |
def start_game(payload: dict): | |
difficulty = payload.get('difficulty', 'easy') if payload else 'easy' | |
player_name = payload.get('player_name', 'Anonymous Player') if payload else 'Anonymous Player' | |
player_google_api_key = payload.get('google_api_key') if payload else None | |
start_location = None | |
if difficulty in zones and zones[difficulty]: | |
selected_zone = random.choice(zones[difficulty]) | |
if selected_zone.get('type') == 'rectangle': | |
bounds = selected_zone['bounds'] | |
north, south, east, west = bounds['north'], bounds['south'], bounds['east'], bounds['west'] | |
if west > east: | |
east += 360 | |
rand_lng = random.uniform(west, east) | |
if rand_lng > 180: | |
rand_lng -= 360 | |
rand_lat = random.uniform(south, north) | |
start_location = {'lat': rand_lat, 'lng': rand_lng} | |
if not start_location: | |
start_location = random.choice(LOCATIONS) | |
game_id = generate_game_id() | |
games[game_id] = { | |
'start_location': start_location, | |
'current_location': start_location, | |
'guesses': [], | |
'moves': 0, | |
'actions': [], | |
'game_over': False, | |
'player_name': player_name, | |
'player_google_api_key': player_google_api_key, | |
'created_at': __import__('datetime').datetime.now().isoformat() | |
} | |
google_maps_api_key = player_google_api_key or os.getenv('GOOGLE_MAPS_API_KEY') | |
streetview_image = None | |
compass_heading = random.randint(0, 359) | |
if google_maps_api_key: | |
try: | |
lat, lng = start_location['lat'], start_location['lng'] | |
streetview_url = f"https://maps.googleapis.com/maps/api/streetview?size=640x400&location={lat},{lng}&heading={compass_heading}&pitch=0&fov=90&key={google_maps_api_key}" | |
response = requests.get(streetview_url, timeout=20) | |
if response.status_code == 200: | |
base_image = base64.b64encode(response.content).decode('utf-8') | |
streetview_image = draw_compass_on_image(base_image, compass_heading) | |
except Exception as e: | |
print(f"Error fetching Street View image: {e}") | |
return { | |
'game_id': game_id, | |
'player_name': player_name, | |
'streetview_image': streetview_image, | |
'compass_heading': compass_heading | |
} | |
def get_game_state(game_id: str): | |
game = games.get(game_id) | |
if not game: | |
raise HTTPException(status_code=404, detail='Game not found') | |
return game | |
def move(game_id: str, payload: dict): | |
game = games.get(game_id) | |
if not game: | |
raise HTTPException(status_code=404, detail='Game not found') | |
if game['game_over']: | |
raise HTTPException(status_code=400, detail='Game is over') | |
direction = payload.get('direction') if payload else None | |
degree = payload.get('degree') if payload else None | |
distance = payload.get('distance', 0.1) if payload else 0.1 | |
if direction is None and degree is None: | |
raise HTTPException(status_code=400, detail='Must provide either direction (N, NE, E, etc.) or degree (0-360)') | |
if direction is not None: | |
degree = direction_to_degree(direction) | |
if degree is None: | |
raise HTTPException(status_code=400, detail='Invalid direction. Use N, NE, E, SE, S, SW, W, NW or their full names') | |
if not (0 <= degree <= 360): | |
raise HTTPException(status_code=400, detail='Degree must be between 0 and 360') | |
if not (0.01 <= distance <= 10): | |
raise HTTPException(status_code=400, detail='Distance must be between 0.01 and 10 km') | |
current_lat = game['current_location']['lat'] | |
current_lng = game['current_location']['lng'] | |
new_lat, new_lng = calculate_new_location(current_lat, current_lng, degree, distance) | |
game['current_location'] = {'lat': new_lat, 'lng': new_lng} | |
game['moves'] += 1 | |
game['actions'].append({ | |
'type': 'move', | |
'location': {'lat': new_lat, 'lng': new_lng}, | |
'direction': direction, | |
'degree': degree, | |
'distance_km': distance | |
}) | |
google_maps_api_key = game.get('player_google_api_key') or os.getenv('GOOGLE_MAPS_API_KEY') | |
streetview_image = None | |
compass_heading = random.randint(0, 359) | |
if google_maps_api_key: | |
try: | |
streetview_url = f"https://maps.googleapis.com/maps/api/streetview?size=640x400&location={new_lat},{new_lng}&heading={compass_heading}&pitch=0&fov=90&key={google_maps_api_key}" | |
response = requests.get(streetview_url, timeout=20) | |
if response.status_code == 200: | |
base_image = base64.b64encode(response.content).decode('utf-8') | |
streetview_image = draw_compass_on_image(base_image, compass_heading) | |
except Exception as e: | |
print(f"Error fetching Street View image: {e}") | |
return { | |
'message': 'Move successful', | |
'streetview_image': streetview_image, | |
'compass_heading': compass_heading, | |
'moved_direction': direction or f"{degree}°", | |
'distance_moved_km': distance | |
} | |
def guess(game_id: str, payload: dict): | |
game = games.get(game_id) | |
if not game: | |
raise HTTPException(status_code=404, detail='Game not found') | |
if game['game_over']: | |
raise HTTPException(status_code=400, detail='Game is over') | |
guess_lat = payload.get('lat') if payload else None | |
guess_lng = payload.get('lng') if payload else None | |
if guess_lat is None or guess_lng is None: | |
raise HTTPException(status_code=400, detail='Missing lat/lng for guess') | |
guess_location = {'lat': guess_lat, 'lng': guess_lng} | |
game['guesses'].append(guess_location) | |
from math import radians, cos, sin, asin, sqrt | |
def haversine(lat1, lon1, lat2, lon2): | |
lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) | |
dlon = lon2 - lon1 | |
dlat = lat2 - lat1 | |
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 | |
c = 2 * asin(sqrt(a)) | |
r = 6371 | |
return c * r | |
distance = haversine( | |
game['start_location']['lat'], game['start_location']['lng'], | |
guess_lat, guess_lng | |
) | |
max_score = 5000 | |
score = max(0, max_score - distance) | |
game['actions'].append({ | |
'type': 'guess', | |
'location': guess_location, | |
'result': { | |
'distance_km': distance, | |
'score': score | |
} | |
}) | |
game['game_over'] = True | |
return { | |
'message': 'Guess received', | |
'guess_location': guess_location, | |
'actual_location': game['start_location'], | |
'distance_km': distance, | |
'score': score | |
} | |
# Load zones at startup | |
load_zones_from_file() | |