File size: 18,566 Bytes
8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 706afd7 8398097 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 |
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) |