Geogussr / app.py
Jofthomas's picture
Update app.py
706afd7 verified
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/<game_id>/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/<game_id>/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/<game_id>/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)