import os import random import json import uuid import requests import base64 import math from flask import Flask, jsonify, render_template, request, Response from dotenv import load_dotenv from functools import wraps from PIL import Image, ImageDraw, ImageFont import io load_dotenv() app = Flask(__name__) app.config['SECRET_KEY'] = os.urandom(24) # In-memory "database" ZONES_FILE = 'zones.json' games = {} zones = { "easy": [], "medium": [], "hard": [] } # --- Zone Persistence Functions --- def save_zones_to_file(): with open(ZONES_FILE, 'w') as f: json.dump(zones, f, indent=4) def load_zones_from_file(): global zones if os.path.exists(ZONES_FILE): try: with open(ZONES_FILE, 'r') as f: loaded_zones = json.load(f) # Basic format validation if not (isinstance(loaded_zones, dict) and all(k in loaded_zones for k in ["easy", "medium", "hard"])): raise ValueError("Invalid format") migrated = False for difficulty in loaded_zones: for zone in loaded_zones[difficulty]: # Assign ID if missing if 'id' not in zone: zone['id'] = uuid.uuid4().hex migrated = True zones = loaded_zones print(zones) if migrated: print("Info: Migrated old zone data by adding unique IDs.") save_zones_to_file() except (json.JSONDecodeError, IOError, ValueError): print(f"Warning: '{ZONES_FILE}' is corrupted or invalid. Recreating with empty zones.") save_zones_to_file() # This creates a fresh, empty, and valid file else: # If file doesn't exist, create an empty one. save_zones_to_file() # Predefined locations for the game, used as a fallback 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(): return ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) def draw_compass_on_image(image_data, heading): """Draw a compass overlay on a Street View image""" try: # Convert base64 to PIL Image img = Image.open(io.BytesIO(base64.b64decode(image_data))) # Create a copy to draw on img_with_compass = img.copy() draw = ImageDraw.Draw(img_with_compass) # Compass parameters 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 compass background (semi-transparent circle) # Since PIL doesn't support transparency well, we'll use a light color compass_bg_color = (255, 255, 255, 200) # White with some transparency effect draw.ellipse([x, y, x + compass_size, y + compass_size], fill=(240, 240, 240), outline=(249, 115, 22), width=3) # Draw cardinal directions font_size = 12 try: # Try to use a system font, fall back to default if not available font = ImageFont.truetype("/System/Library/Fonts/Arial.ttf", font_size) except: try: font = ImageFont.truetype("arial.ttf", font_size) except: font = ImageFont.load_default() # Draw N, E, S, W directions = [ ("N", center_x, y + 8, (220, 38, 38)), # Red for North ("E", x + compass_size - 15, center_y, (249, 115, 22)), # Orange for East ("S", center_x, y + compass_size - 20, (249, 115, 22)), # Orange for South ("W", x + 8, center_y, (249, 115, 22)) # Orange for West ] for text, text_x, text_y, color in directions: # Get text dimensions for centering bbox = draw.textbbox((0, 0), text, font=font) text_width = bbox[2] - bbox[0] text_height = bbox[3] - bbox[1] # Draw small circle background for letter 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 the letter draw.text((text_x - text_width//2, text_y - text_height//2), text, font=font, fill=color) # Draw compass needle needle_length = compass_size // 2 - 15 needle_angle = math.radians(heading) # Calculate needle points end_x = center_x + needle_length * math.sin(needle_angle) end_y = center_y - needle_length * math.cos(needle_angle) # Draw needle (thick line) draw.line([center_x, center_y, end_x, end_y], fill=(220, 38, 38), width=4) # Draw needle tip (small circle) 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)) # Draw center dot 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)) # Draw compass label 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)) # Convert back to base64 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 original image if compass drawing fails return image_data # --- Authentication --- def check_auth(username, password): """This function is called to check if a username / password combination is valid. """ admin_user = os.getenv('ADMIN_USERNAME', 'admin') admin_pass = os.getenv('ADMIN_PASSWORD', 'password') return username == admin_user and password == admin_pass def authenticate(): """Sends a 401 response that enables basic auth""" return Response( 'Could not verify your access level for that URL.\\n' 'You have to login with proper credentials', 401, {'WWW-Authenticate': 'Basic realm="Login Required"'}) def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): auth = request.authorization if not auth or not check_auth(auth.username, auth.password): return authenticate() return f(*args, **kwargs) return decorated @app.route('/') def index(): google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') if not google_maps_api_key: return "Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", 500 return render_template('index.html', google_maps_api_key=google_maps_api_key) @app.route('/admin') @requires_auth def admin(): google_maps_api_key = os.getenv('GOOGLE_MAPS_API_KEY') if not google_maps_api_key: return "Error: GOOGLE_MAPS_API_KEY not set. Please set it in a .env file.", 500 return render_template('admin.html', google_maps_api_key=google_maps_api_key) @app.route('/api/zones', methods=['GET', 'POST', 'DELETE']) def handle_zones(): if request.method == 'POST': data = request.json difficulty = data.get('difficulty') zone_data = data.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 jsonify({'message': 'Zone saved successfully'}), 201 return jsonify({'error': 'Invalid data'}), 400 if request.method == 'DELETE': data = request.json zone_id = data.get('zone_id') if not zone_id: return jsonify({'error': 'Zone ID is required'}), 400 for difficulty in zones: zones[difficulty] = [z for z in zones[difficulty] if z.get('id') != zone_id] save_zones_to_file() return jsonify({'message': 'Zone deleted successfully'}) # GET request return jsonify(zones) @app.route('/start_game', methods=['POST']) def start_game(): data = request.json or {} difficulty = data.get('difficulty', 'easy') player_name = data.get('player_name', 'Anonymous Player') player_google_api_key = data.get('google_api_key') # Optional player-provided API key start_location = None if difficulty in zones and zones[difficulty]: selected_zone_list = zones[difficulty] print("selected_zone_list", selected_zone_list) selected_zone = random.choice(selected_zone_list) print("selected_zone", selected_zone) if selected_zone['type'] == 'rectangle': bounds = selected_zone['bounds'] north, south, east, west = bounds['north'], bounds['south'], bounds['east'], bounds['west'] # Handle antimeridian crossing 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} # Fallback to predefined locations if no zones are defined 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() } # Use player-provided API key if available, otherwise fall back to server key google_maps_api_key = player_google_api_key or os.getenv('GOOGLE_MAPS_API_KEY') # Fetch Street View image streetview_image = None compass_heading = random.randint(0, 359) # Random compass direction 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) if response.status_code == 200: # Convert image to base64 base_image = base64.b64encode(response.content).decode('utf-8') # Add compass overlay streetview_image = draw_compass_on_image(base_image, compass_heading) except Exception as e: print(f"Error fetching Street View image: {e}") return jsonify({ 'game_id': game_id, 'player_name': player_name, 'streetview_image': streetview_image, 'compass_heading': compass_heading }) @app.route('/game//state', methods=['GET']) def get_game_state(game_id): game = games.get(game_id) if not game: return jsonify({'error': 'Game not found'}), 404 return jsonify(game) def direction_to_degree(direction): """Convert direction string to degrees""" 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()) def calculate_new_location(current_lat, current_lng, degree, distance_km=0.1): """Calculate new coordinates based on current location, direction in degrees, and distance""" # Convert to radians lat_rad = math.radians(current_lat) lng_rad = math.radians(current_lng) bearing_rad = math.radians(degree) # Earth's radius in kilometers R = 6371.0 # Calculate new latitude 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) ) # Calculate new longitude 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) ) # Convert back to degrees new_lat = math.degrees(new_lat_rad) new_lng = math.degrees(new_lng_rad) # Normalize longitude to [-180, 180] new_lng = ((new_lng + 180) % 360) - 180 return new_lat, new_lng @app.route('/game//move', methods=['POST']) def move(game_id): game = games.get(game_id) if not game: return jsonify({'error': 'Game not found'}), 404 if game['game_over']: return jsonify({'error': 'Game is over'}), 400 data = request.json direction = data.get('direction') degree = data.get('degree') distance = data.get('distance', 0.1) # Default distance in km # Validate input - must have either direction or degree if direction is None and degree is None: return jsonify({'error': 'Must provide either direction (N, NE, E, etc.) or degree (0-360)'}), 400 # Convert direction to degree if direction is provided if direction is not None: degree = direction_to_degree(direction) if degree is None: return jsonify({'error': 'Invalid direction. Use N, NE, E, SE, S, SW, W, NW or their full names'}), 400 # Validate degree if not (0 <= degree <= 360): return jsonify({'error': 'Degree must be between 0 and 360'}), 400 # Validate distance if not (0.01 <= distance <= 10): # Between 10m and 10km return jsonify({'error': 'Distance must be between 0.01 and 10 km'}), 400 # Get current location and calculate new location 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 }) # Fetch Street View image for the new location # Check if game has a player-provided API key, otherwise use server key 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) # Random compass direction 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) if response.status_code == 200: # Convert image to base64 base_image = base64.b64encode(response.content).decode('utf-8') # Add compass overlay streetview_image = draw_compass_on_image(base_image, compass_heading) except Exception as e: print(f"Error fetching Street View image: {e}") return jsonify({ 'message': 'Move successful', 'streetview_image': streetview_image, 'compass_heading': compass_heading, 'moved_direction': direction or f"{degree}°", 'distance_moved_km': distance }) @app.route('/game//guess', methods=['POST']) def guess(game_id): game = games.get(game_id) if not game: return jsonify({'error': 'Game not found'}), 404 if game['game_over']: return jsonify({'error': 'Game is over'}), 400 data = request.json guess_lat = data.get('lat') guess_lng = data.get('lng') if guess_lat is None or guess_lng is None: return jsonify({'error': 'Missing lat/lng for guess'}), 400 guess_location = {'lat': guess_lat, 'lng': guess_lng} game['guesses'].append(guess_location) # Calculate score (simple distance for now) # This is a placeholder for a more complex scoring function. from math import radians, cos, sin, asin, sqrt def haversine(lat1, lon1, lat2, lon2): # convert decimal degrees to radians lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) # haversine formula 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 # Radius of earth in kilometers. return c * r distance = haversine( game['start_location']['lat'], game['start_location']['lng'], guess_lat, guess_lng ) # Simple scoring max_score = 5000 score = max(0, max_score - distance) # The closer, the higher the score game['actions'].append({ 'type': 'guess', 'location': guess_location, 'result': { 'distance_km': distance, 'score': score } }) game['game_over'] = True # For now, one guess ends the game. return jsonify({ '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() if __name__ == '__main__': app.run(debug=True)