|
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) |
|
|
|
|
|
ZONES_FILE = 'zones.json' |
|
games = {} |
|
zones = { |
|
"easy": [], |
|
"medium": [], |
|
"hard": [] |
|
} |
|
|
|
|
|
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) |
|
|
|
|
|
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]: |
|
|
|
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() |
|
else: |
|
|
|
save_zones_to_file() |
|
|
|
|
|
LOCATIONS = [ |
|
{'lat': 48.85824, 'lng': 2.2945}, |
|
{'lat': 40.748440, 'lng': -73.985664}, |
|
{'lat': 35.689487, 'lng': 139.691711}, |
|
{'lat': -33.856784, 'lng': 151.215297} |
|
] |
|
|
|
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: |
|
|
|
img = Image.open(io.BytesIO(base64.b64decode(image_data))) |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
compass_bg_color = (255, 255, 255, 200) |
|
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: |
|
try: |
|
font = ImageFont.truetype("arial.ttf", font_size) |
|
except: |
|
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 |
|
|
|
|
|
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'}) |
|
|
|
|
|
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') |
|
|
|
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'] |
|
|
|
|
|
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) |
|
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 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""" |
|
|
|
|
|
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 |
|
|
|
@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) |
|
|
|
|
|
if direction is None and degree is None: |
|
return jsonify({'error': 'Must provide either direction (N, NE, E, etc.) or degree (0-360)'}), 400 |
|
|
|
|
|
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 |
|
|
|
|
|
if not (0 <= degree <= 360): |
|
return jsonify({'error': 'Degree must be between 0 and 360'}), 400 |
|
|
|
|
|
if not (0.01 <= distance <= 10): |
|
return jsonify({'error': 'Distance must be between 0.01 and 10 km'}), 400 |
|
|
|
|
|
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) |
|
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 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) |
|
|
|
|
|
|
|
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 jsonify({ |
|
'message': 'Guess received', |
|
'guess_location': guess_location, |
|
'actual_location': game['start_location'], |
|
'distance_km': distance, |
|
'score': score |
|
}) |
|
|
|
|
|
load_zones_from_file() |
|
|
|
if __name__ == '__main__': |
|
app.run(debug=True) |