Spaces:
Runtime error
Runtime error
| from flask import Flask, render_template_string, request, redirect, url_for, session, jsonify | |
| import random | |
| import string | |
| import json | |
| import os | |
| from flask_socketio import SocketIO, join_room, leave_room, emit | |
| import hashlib | |
| import time | |
| import threading | |
| from datetime import datetime | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import RepositoryNotFoundError | |
| from urllib.parse import urlparse, parse_qs | |
| app = Flask(__name__) | |
| app.config['SECRET_KEY'] = 'your-very-secret-key-here' | |
| socketio = SocketIO(app, async_mode='threading') | |
| REPO_ID = "flpolprojects/Clients" | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") | |
| ROOMS_DB = os.path.join(app.root_path, 'rooms.json') | |
| USERS_DB = os.path.join(app.root_path, 'users.json') | |
| GAMES_DB = os.path.join(app.root_path, 'games.json') | |
| def load_json(file_path, default={}): | |
| try: | |
| download_db_from_hf() | |
| if os.path.exists(file_path): | |
| with open(file_path, 'r', encoding='utf-8') as f: | |
| return json.load(f) | |
| return default | |
| except (FileNotFoundError, json.JSONDecodeError) as e: | |
| print(f"Ошибка загрузки JSON из {file_path}: {e}") | |
| return default | |
| except Exception as e: | |
| print(f"Непредвиденная ошибка при загрузке: {e}") | |
| return default | |
| def save_json(file_path, data): | |
| try: | |
| with open(file_path, 'w', encoding='utf-8') as f: | |
| json.dump(data, f, indent=4, ensure_ascii=False) | |
| upload_db_to_hf() | |
| except OSError as e: | |
| print(f"Ошибка сохранения JSON в {file_path}: {e}") | |
| except Exception as e: | |
| print(f"Непредвиденная ошибка при сохранении: {e}") | |
| def upload_db_to_hf(): | |
| try: | |
| api = HfApi() | |
| for file_path, repo_path in [(ROOMS_DB, "rooms.json"), (USERS_DB, "users.json"), (GAMES_DB, "games.json")]: | |
| if os.path.exists(file_path): | |
| api.upload_file( | |
| path_or_fileobj=file_path, | |
| path_in_repo=repo_path, | |
| repo_id=REPO_ID, | |
| repo_type="dataset", | |
| token=HF_TOKEN_WRITE, | |
| commit_message=f"Backup: {repo_path} ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})" | |
| ) | |
| except Exception as e: | |
| print(f"Ошибка при загрузке файлов на Hugging Face Hub: {e}") | |
| def download_db_from_hf(): | |
| try: | |
| api = HfApi() | |
| for file_path, repo_path in [(ROOMS_DB, "rooms.json"), (USERS_DB, "users.json"), (GAMES_DB, "games.json")]: | |
| try: | |
| hf_hub_download( | |
| repo_id=REPO_ID, | |
| filename=repo_path, | |
| repo_type="dataset", | |
| token=HF_TOKEN_READ, | |
| local_dir=".", | |
| local_dir_use_symlinks=False | |
| ) | |
| except RepositoryNotFoundError: | |
| if not os.path.exists(file_path): | |
| with open(file_path, 'w') as f: | |
| json.dump({}, f) | |
| except Exception as e: | |
| print(f"Ошибка при скачивании файла {repo_path}: {e}") | |
| except Exception as e: | |
| print(f"Ошибка при скачивании файлов с Hugging Face Hub: {e}") | |
| def periodic_backup(): | |
| while True: | |
| upload_db_to_hf() | |
| time.sleep(15) | |
| rooms = load_json(ROOMS_DB) | |
| users = load_json(USERS_DB) | |
| games_data = load_json(GAMES_DB, default={ | |
| "crocodile": { | |
| "name": "Крокодил", | |
| "description": "Один игрок показывает слово жестами.", | |
| "min_players": 2, | |
| "max_players": 10, | |
| "state": {} | |
| }, | |
| "alias": { | |
| "name": "Alias", | |
| "description": "Один игрок объясняет слово.", | |
| "min_players": 2, | |
| "max_players": 10, | |
| "state": {} | |
| }, | |
| "mafia": { | |
| "name": "Мафия", | |
| "description": "Мафия против мирных жителей.", | |
| "min_players": 4, | |
| "max_players": 10, | |
| "state": {} | |
| }, | |
| "durak": { | |
| "name": "Дурак", | |
| "description": "Карточная игра.", | |
| "min_players": 2, | |
| "max_players": 6, | |
| "state": {} | |
| } | |
| }) | |
| save_json(GAMES_DB, games_data) | |
| def generate_token(): | |
| return ''.join(random.choices(string.ascii_letters + string.digits, k=15)) | |
| def hash_password(password): | |
| return hashlib.sha256(password.encode('utf-8')).hexdigest() | |
| def get_youtube_id(url): | |
| if not url: | |
| return None | |
| parsed_url = urlparse(url) | |
| if parsed_url.hostname in ('www.youtube.com', 'youtube.com'): | |
| if parsed_url.path == '/watch': | |
| query = parse_qs(parsed_url.query) | |
| return query.get('v', [None])[0] | |
| elif parsed_url.path.startswith('/embed/'): | |
| return parsed_url.path.split('/embed/')[1].split('?')[0] | |
| elif parsed_url.path.startswith('/v/'): | |
| return parsed_url.path.split('/v/')[1].split('?')[0] | |
| elif parsed_url.hostname in ('youtu.be', 'www.youtu.be'): | |
| return parsed_url.path[1:].split('?')[0] | |
| return None | |
| def index(): | |
| if 'username' in session: | |
| return redirect(url_for('dashboard')) | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| username = request.form.get('username') | |
| password = request.form.get('password') | |
| if action == 'register': | |
| if username in users: | |
| return "Пользователь уже существует", 400 | |
| users[username] = {'password': hash_password(password), 'rooms': []} | |
| save_json(USERS_DB, users) | |
| session['username'] = username | |
| return redirect(url_for('dashboard')) | |
| elif action == 'login': | |
| if username in users and users[username]['password'] == hash_password(password): | |
| session['username'] = username | |
| return redirect(url_for('dashboard')) | |
| return "Неверный логин или пароль", 401 | |
| return render_template_string(''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Room</title> | |
| <style> | |
| :root { | |
| --primary-color: #6200ee; | |
| --secondary-color: #3700b3; | |
| --background-color: #ffffff; | |
| --surface-color: #f5f5f5; | |
| --text-color: #333333; | |
| --error-color: #b00020; | |
| --font-family: 'Roboto', sans-serif; | |
| --border-radius: 12px; | |
| --box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--background-color); | |
| color: var(--text-color); | |
| margin: 0; | |
| padding: 0; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| } | |
| .container { | |
| background-color: var(--surface-color); | |
| padding: 2rem; | |
| border-radius: var(--border-radius); | |
| box-shadow: var(--box-shadow); | |
| width: 90%; | |
| max-width: 400px; | |
| text-align: center; | |
| } | |
| h1 { | |
| font-size: 2rem; | |
| margin-bottom: 1.5rem; | |
| color: var(--primary-color); | |
| } | |
| input, button { | |
| display: block; | |
| width: 100%; | |
| padding: 0.75rem; | |
| margin-bottom: 1rem; | |
| border: 1px solid #ccc; | |
| border-radius: var(--border-radius); | |
| font-size: 1rem; | |
| box-sizing: border-box; | |
| transition: border-color 0.3s ease; | |
| } | |
| input:focus { | |
| outline: none; | |
| border-color: var(--primary-color); | |
| } | |
| button { | |
| background-color: var(--primary-color); | |
| color: white; | |
| cursor: pointer; | |
| border: none; | |
| font-weight: 500; | |
| transition: background-color 0.3s ease; | |
| } | |
| button:hover { | |
| background-color: var(--secondary-color); | |
| } | |
| button:active { | |
| opacity: 0.8; | |
| } | |
| .error-message { | |
| color: var(--error-color); | |
| margin-top: 0.5rem; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --background-color: #121212; | |
| --surface-color: #1e1e1e; | |
| --text-color: #ffffff; | |
| } | |
| } | |
| </style> | |
| <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Room</h1> | |
| <form method="post"> | |
| <input type="text" name="username" placeholder="Логин" required> | |
| <input type="password" name="password" placeholder="Пароль" required> | |
| <button type="submit" name="action" value="login">Войти</button> | |
| <button type="submit" name="action" value="register">Зарегистрироваться</button> | |
| </form> | |
| </div> | |
| </body> | |
| </html> | |
| ''') | |
| def dashboard(): | |
| if 'username' not in session: | |
| return redirect(url_for('index')) | |
| user = users.get(session['username']) | |
| if not user: | |
| session.pop('username', None) | |
| return redirect(url_for('index')) | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| if action == 'create': | |
| token = generate_token() | |
| rooms[token] = {'users': [session['username']], 'max_users': 10, 'admin': session['username'], 'current_game': None, 'guests': []} | |
| users[session['username']]['rooms'].append(token) | |
| save_json(ROOMS_DB, rooms) | |
| save_json(USERS_DB, users) | |
| return redirect(url_for('room', token=token)) | |
| elif action == 'join': | |
| token = request.form.get('token') | |
| if token in rooms and len(rooms[token]['users']) + len(rooms[token]['guests']) < rooms[token]['max_users']: | |
| if session['username'] not in rooms[token]['users']: | |
| rooms[token]['users'].append(session['username']) | |
| users[session['username']]['rooms'].append(token) | |
| save_json(ROOMS_DB, rooms) | |
| save_json(USERS_DB, users) | |
| return redirect(url_for('room', token=token)) | |
| return "Комната не найдена или переполнена", 404 | |
| return render_template_string(''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Панель управления</title> | |
| <style> | |
| :root { | |
| --primary-color: #6200ee; | |
| --secondary-color: #3700b3; | |
| --background-color: #ffffff; | |
| --surface-color: #f5f5f5; | |
| --text-color: #333333; | |
| --accent-color: #03dac6; | |
| --font-family: 'Roboto', sans-serif; | |
| --border-radius: 12px; | |
| --box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--background-color); | |
| color: var(--text-color); | |
| margin: 0; | |
| padding: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| min-height: 100vh; | |
| } | |
| .container { | |
| background-color: var(--surface-color); | |
| padding: 2rem; | |
| border-radius: var(--border-radius); | |
| box-shadow: var(--box-shadow); | |
| width: 90%; | |
| max-width: 400px; | |
| text-align: center; | |
| margin-top: 2rem; | |
| } | |
| h1 { | |
| font-size: 2rem; | |
| margin-bottom: 1.5rem; | |
| color: var(--primary-color); | |
| } | |
| input, button { | |
| display: block; | |
| width: 100%; | |
| padding: 0.75rem; | |
| margin-bottom: 1rem; | |
| border: 1px solid #ccc; | |
| border-radius: var(--border-radius); | |
| font-size: 1rem; | |
| box-sizing: border-box; | |
| transition: border-color 0.3s ease; | |
| } | |
| input:focus { | |
| outline: none; | |
| border-color: var(--primary-color); | |
| } | |
| button { | |
| background-color: var(--primary-color); | |
| color: white; | |
| cursor: pointer; | |
| border: none; | |
| font-weight: 500; | |
| transition: background-color 0.3s ease; | |
| } | |
| button:hover { | |
| background-color: var(--secondary-color); | |
| } | |
| button:active { | |
| opacity: 0.8; | |
| } | |
| .logout-button { | |
| background-color: var(--accent-color); | |
| margin-top: 1rem; | |
| transition: background-color 0.3s ease; | |
| } | |
| .logout-button:hover { | |
| filter: brightness(0.9); | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --background-color: #121212; | |
| --surface-color: #1e1e1e; | |
| --text-color: #ffffff; | |
| } | |
| } | |
| </style> | |
| <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet"> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Добро пожаловать, {{ session['username'] }}</h1> | |
| <form method="post"> | |
| <button type="submit" name="action" value="create">Создать комнату</button> | |
| </form> | |
| <form method="post"> | |
| <input type="text" name="token" placeholder="Введите токен комнаты" required> | |
| <button type="submit" name="action" value="join">Войти в комнату</button> | |
| </form> | |
| <form action="/logout" method="post"> | |
| <button class="logout-button" type="submit">Выйти</button> | |
| </form> | |
| </div> | |
| </body> | |
| </html> | |
| ''', session=session) | |
| def logout(): | |
| session.pop('username', None) | |
| return redirect(url_for('index')) | |
| def room(token): | |
| if 'username' not in session and 'guest_id' not in session: | |
| return redirect(url_for('index')) | |
| if token not in rooms: | |
| return redirect(url_for('dashboard')) | |
| is_admin = rooms[token]['admin'] == session.get('username') | |
| if 'username' in session: | |
| username = session['username'] | |
| is_guest = False | |
| elif 'guest_id' in session: | |
| username = session['guest_id'] | |
| is_guest = True | |
| if username not in rooms[token]['guests']: | |
| rooms[token]['guests'].append(username) | |
| save_json(ROOMS_DB, rooms) | |
| else: | |
| return redirect(url_for('guest_login', token=token)) | |
| return render_template_string(''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Метавселенная - Комната {{ token }}</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |
| background-color: #111; | |
| color: #fff; | |
| } | |
| canvas { | |
| display: block; | |
| } | |
| #blocker { | |
| position: absolute; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(0,0,0,0.7); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| text-align: center; | |
| font-size: 24px; | |
| cursor: pointer; | |
| } | |
| #instructions { | |
| width: 50%; | |
| background: rgba(20,20,20,0.8); | |
| padding: 20px; | |
| border-radius: 10px; | |
| } | |
| .video-chat-overlay { | |
| position: absolute; | |
| top: 10px; | |
| right: 10px; | |
| width: 250px; | |
| background: rgba(0,0,0,0.5); | |
| border-radius: 8px; | |
| padding: 5px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| .video-container { | |
| position: relative; | |
| } | |
| video { | |
| width: 100%; | |
| border-radius: 5px; | |
| } | |
| .user-indicator { | |
| position: absolute; | |
| bottom: 5px; | |
| left: 5px; | |
| background: rgba(0,0,0,0.6); | |
| color: white; | |
| padding: 2px 5px; | |
| border-radius: 3px; | |
| font-size: 12px; | |
| } | |
| #crosshair { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| width: 10px; | |
| height: 10px; | |
| border: 1px solid white; | |
| border-radius: 50%; | |
| transform: translate(-50%, -50%); | |
| } | |
| .hotspot-prompt { | |
| position: absolute; | |
| bottom: 20%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px 20px; | |
| border-radius: 5px; | |
| font-size: 16px; | |
| display: none; | |
| } | |
| .modal-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0,0,0,0.8); | |
| display: none; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 1000; | |
| } | |
| .modal-content { | |
| background: #222; | |
| padding: 20px; | |
| border-radius: 10px; | |
| max-width: 90%; | |
| max-height: 90%; | |
| overflow-y: auto; | |
| position: relative; | |
| } | |
| .modal-close { | |
| position: absolute; | |
| top: 10px; | |
| right: 15px; | |
| font-size: 24px; | |
| cursor: pointer; | |
| } | |
| #youtube-player-container { | |
| width: 80vw; | |
| height: 45vw; | |
| max-width: 1280px; | |
| max-height: 720px; | |
| } | |
| #game-display { | |
| padding: 20px; | |
| background-color: #333; | |
| border-radius: 10px; | |
| text-align: center; | |
| width: 80vw; | |
| max-width: 600px; | |
| } | |
| .game-input, .game-button { | |
| padding: 10px; | |
| margin: 5px; | |
| border-radius: 5px; | |
| border: 1px solid #555; | |
| background: #444; | |
| color: #fff; | |
| } | |
| #mobile-controls { | |
| position: absolute; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 40%; | |
| display: none; | |
| z-index: 10; | |
| } | |
| #joystick-zone { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 20px; | |
| width: 120px; | |
| height: 120px; | |
| background: rgba(255,255,255,0.2); | |
| border-radius: 50%; | |
| } | |
| #joystick-thumb { | |
| position: absolute; | |
| width: 60px; | |
| height: 60px; | |
| background: rgba(255,255,255,0.4); | |
| border-radius: 50%; | |
| top: 30px; | |
| left: 30px; | |
| } | |
| #look-zone { | |
| position: absolute; | |
| bottom: 0; | |
| right: 0; | |
| width: 50%; | |
| height: 100%; | |
| } | |
| .leave-button { | |
| position: absolute; | |
| top: 20px; | |
| left: 20px; | |
| padding: 10px 15px; | |
| background: #f44336; | |
| color: white; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| z-index: 20; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="blocker"> | |
| <div id="instructions"> | |
| <p style="font-size: 36px;">Нажмите, чтобы войти в метавселенную</p> | |
| <p>Движение: W, A, S, D<br/>Осмотр: Мышь<br/>Взаимодействие: E</p> | |
| <p>На мобильных устройствах используйте джойстик и правую часть экрана</p> | |
| </div> | |
| </div> | |
| <div class="video-chat-overlay" id="video-grid"></div> | |
| <div id="crosshair">+</div> | |
| <div class="hotspot-prompt" id="hotspot-prompt">Нажмите [E] для взаимодействия</div> | |
| <div class="modal-overlay" id="youtube-modal"> | |
| <div class="modal-content"> | |
| <span class="modal-close" id="youtube-close">×</span> | |
| <div id="youtube-player-container"></div> | |
| </div> | |
| </div> | |
| <div class="modal-overlay" id="info-modal"> | |
| <div class="modal-content"> | |
| <span class="modal-close" id="info-close">×</span> | |
| <p id="info-text"></p> | |
| </div> | |
| </div> | |
| <div class="modal-overlay" id="game-modal"> | |
| <div class="modal-content"> | |
| <span class="modal-close" id="game-close">×</span> | |
| <div id="game-display"> | |
| <h2></h2> | |
| <p id="game-description"></p> | |
| <div id="game-content"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="mobile-controls"> | |
| <div id="joystick-zone"> | |
| <div id="joystick-thumb"></div> | |
| </div> | |
| <div id="look-zone"></div> | |
| </div> | |
| <button class="leave-button" onclick="leaveRoom()">Покинуть комнату</button> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.js"></script> | |
| <script src="https://www.youtube.com/iframe_api"></script> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.165.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.165.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js'; | |
| const socket = io(); | |
| const token = '{{ token }}'; | |
| const username = '{{ username }}'; | |
| const is_guest = {{ is_guest|tojson }}; | |
| const isAdmin = {{ is_admin|tojson }}; | |
| let camera, scene, renderer, controls; | |
| const objects = []; | |
| let raycaster; | |
| let moveForward = false; | |
| let moveBackward = false; | |
| let moveLeft = false; | |
| let moveRight = false; | |
| let canJump = false; | |
| let prevTime = performance.now(); | |
| const velocity = new THREE.Vector3(); | |
| const direction = new THREE.Vector3(); | |
| const players = {}; | |
| let localStream; | |
| const peers = {}; | |
| const iceConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; | |
| const hotspots = [ | |
| { pos: new THREE.Vector3(5, 1.5, -10), type: 'youtube', data: 'dQw4w9WgXcQ', name: 'Секретное видео' }, | |
| { pos: new THREE.Vector3(-8, 1.5, -8), type: 'info', data: 'Добро пожаловать в нашу метавселенную! Исследуйте мир и общайтесь с друзьями.', name: 'Приветствие' }, | |
| { pos: new THREE.Vector3(0, 1.5, 10), type: 'game', data: 'crocodile', name: 'Играть в Крокодила' } | |
| ]; | |
| let currentHotspot = null; | |
| let youtubePlayer; | |
| const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); | |
| init(); | |
| animate(); | |
| function init() { | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.y = 1.6; | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| scene.fog = new THREE.Fog(0x87ceeb, 0, 75); | |
| const light = new THREE.HemisphereLight(0xeeeeff, 0x777788, 0.9); | |
| light.position.set(0.5, 1, 0.75); | |
| scene.add(light); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 0.7); | |
| dirLight.position.set(-1, 1.75, 1); | |
| dirLight.position.multiplyScalar(30); | |
| scene.add(dirLight); | |
| raycaster = new THREE.Raycaster(new THREE.Vector3(), new THREE.Vector3(0, -1, 0), 0, 10); | |
| const mapSize = 20; | |
| const floorGeometry = new THREE.PlaneGeometry(mapSize*2, mapSize*2, 100, 100); | |
| const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x4a7d2c }); | |
| const floor = new THREE.Mesh(floorGeometry, floorMaterial); | |
| floor.rotation.x = -Math.PI / 2; | |
| scene.add(floor); | |
| const boxGeometry = new THREE.BoxGeometry(1, 1, 1); | |
| const mapLayout = [ | |
| "####################", | |
| "# #", | |
| "# #### #### #", | |
| "# # # # # #", | |
| "# #### #### #", | |
| "# #", | |
| "# ###### #", | |
| "# # # #", | |
| "# # # #", | |
| "# ###### #", | |
| "# #", | |
| "# #", | |
| "# #### #### #", | |
| "# # # # # #", | |
| "# # # # # #", | |
| "# #### #### #", | |
| "# #", | |
| "# #", | |
| "# #", | |
| "####################", | |
| ]; | |
| for(let z=0; z<mapLayout.length; z++) { | |
| for(let x=0; x<mapLayout[z].length; x++) { | |
| if (mapLayout[z][x] === '#') { | |
| const wallMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 }); | |
| const wall = new THREE.Mesh(boxGeometry, wallMaterial); | |
| wall.position.set(x - mapSize/2 + 0.5, 0.5, z - mapSize/2 + 0.5); | |
| scene.add(wall); | |
| objects.push(wall); | |
| const wallTop = new THREE.Mesh(boxGeometry, wallMaterial); | |
| wallTop.position.set(x - mapSize/2 + 0.5, 1.5, z - mapSize/2 + 0.5); | |
| scene.add(wallTop); | |
| objects.push(wallTop); | |
| } | |
| } | |
| } | |
| hotspots.forEach(hotspot => { | |
| const hotspotGeometry = new THREE.CylinderGeometry(0.5, 0.5, 0.1, 32); | |
| const hotspotMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, transparent: true, opacity: 0.7 }); | |
| const hotspotMesh = new THREE.Mesh(hotspotGeometry, hotspotMaterial); | |
| hotspotMesh.position.copy(hotspot.pos); | |
| scene.add(hotspotMesh); | |
| hotspot.mesh = hotspotMesh; | |
| }); | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setPixelRatio(window.devicePixelRatio); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| if (isMobile) { | |
| initMobileControls(); | |
| } else { | |
| initPointerLock(); | |
| } | |
| document.addEventListener('keydown', onKeyDown); | |
| document.addEventListener('keyup', onKeyUp); | |
| window.addEventListener('resize', onWindowResize); | |
| document.getElementById('hotspot-prompt').addEventListener('click', interactWithHotspot); | |
| document.getElementById('youtube-close').addEventListener('click', closeYoutubeModal); | |
| document.getElementById('info-close').addEventListener('click', closeInfoModal); | |
| document.getElementById('game-close').addEventListener('click', closeGameModal); | |
| } | |
| function initPointerLock() { | |
| const blocker = document.getElementById('blocker'); | |
| const instructions = document.getElementById('instructions'); | |
| controls = new PointerLockControls(camera, document.body); | |
| instructions.addEventListener('click', function () { | |
| controls.lock(); | |
| }); | |
| controls.addEventListener('lock', function () { | |
| instructions.style.display = 'none'; | |
| blocker.style.display = 'none'; | |
| }); | |
| controls.addEventListener('unlock', function () { | |
| blocker.style.display = 'flex'; | |
| instructions.style.display = ''; | |
| }); | |
| scene.add(controls.getObject()); | |
| } | |
| function initMobileControls() { | |
| document.getElementById('blocker').addEventListener('click', () => { | |
| document.getElementById('blocker').style.display = 'none'; | |
| }); | |
| document.getElementById('mobile-controls').style.display = 'block'; | |
| const joystickZone = document.getElementById('joystick-zone'); | |
| const joystickThumb = document.getElementById('joystick-thumb'); | |
| const lookZone = document.getElementById('look-zone'); | |
| let joystickActive = false; | |
| let joystickStart = { x: 0, y: 0 }; | |
| const joystickCenter = { x: joystickZone.offsetLeft + 60, y: joystickZone.offsetTop + 60 }; | |
| joystickZone.addEventListener('touchstart', (e) => { | |
| e.preventDefault(); | |
| joystickActive = true; | |
| joystickStart.x = e.touches[0].clientX; | |
| joystickStart.y = e.touches[0].clientY; | |
| }, { passive: false }); | |
| joystickZone.addEventListener('touchmove', (e) => { | |
| e.preventDefault(); | |
| if (!joystickActive) return; | |
| let dx = e.touches[0].clientX - joystickStart.x; | |
| let dy = e.touches[0].clientY - joystickStart.y; | |
| let dist = Math.sqrt(dx * dx + dy * dy); | |
| let angle = Math.atan2(dy, dx); | |
| if (dist > 30) { | |
| dx = Math.cos(angle) * 30; | |
| dy = Math.sin(angle) * 30; | |
| } | |
| joystickThumb.style.transform = `translate(${dx}px, ${dy}px)`; | |
| moveForward = dy > 10; | |
| moveBackward = dy < -10; | |
| moveLeft = dx < -10; | |
| moveRight = dx > 10; | |
| }, { passive: false }); | |
| joystickZone.addEventListener('touchend', (e) => { | |
| joystickActive = false; | |
| joystickThumb.style.transform = 'translate(0,0)'; | |
| moveForward = moveBackward = moveLeft = moveRight = false; | |
| }); | |
| let lookActive = false; | |
| let lookStart = { x: 0, y: 0 }; | |
| lookZone.addEventListener('touchstart', (e) => { | |
| e.preventDefault(); | |
| lookActive = true; | |
| lookStart.x = e.touches[0].clientX; | |
| lookStart.y = e.touches[0].clientY; | |
| }, { passive: false }); | |
| lookZone.addEventListener('touchmove', (e) => { | |
| e.preventDefault(); | |
| if (!lookActive) return; | |
| let dx = e.touches[0].clientX - lookStart.x; | |
| let dy = e.touches[0].clientY - lookStart.y; | |
| camera.rotation.y -= dx * 0.002; | |
| camera.rotation.x -= dy * 0.002; | |
| camera.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, camera.rotation.x)); | |
| lookStart.x = e.touches[0].clientX; | |
| lookStart.y = e.touches[0].clientY; | |
| }, { passive: false }); | |
| lookZone.addEventListener('touchend', () => { | |
| lookActive = false; | |
| }); | |
| } | |
| function onWindowResize() { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| function onKeyDown(event) { | |
| switch (event.code) { | |
| case 'ArrowUp': | |
| case 'KeyW': | |
| moveForward = true; | |
| break; | |
| case 'ArrowLeft': | |
| case 'KeyA': | |
| moveLeft = true; | |
| break; | |
| case 'ArrowDown': | |
| case 'KeyS': | |
| moveBackward = true; | |
| break; | |
| case 'ArrowRight': | |
| case 'KeyD': | |
| moveRight = true; | |
| break; | |
| case 'Space': | |
| if (canJump === true) velocity.y += 350; | |
| canJump = false; | |
| break; | |
| case 'KeyE': | |
| interactWithHotspot(); | |
| break; | |
| } | |
| } | |
| function onKeyUp(event) { | |
| switch (event.code) { | |
| case 'ArrowUp': | |
| case 'KeyW': | |
| moveForward = false; | |
| break; | |
| case 'ArrowLeft': | |
| case 'KeyA': | |
| moveLeft = false; | |
| break; | |
| case 'ArrowDown': | |
| case 'KeyS': | |
| moveBackward = false; | |
| break; | |
| case 'ArrowRight': | |
| case 'KeyD': | |
| moveRight = false; | |
| break; | |
| } | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const time = performance.now(); | |
| let isMoving = false; | |
| if (isMobile || controls.isLocked === true) { | |
| const delta = (time - prevTime) / 1000; | |
| velocity.x -= velocity.x * 10.0 * delta; | |
| velocity.z -= velocity.z * 10.0 * delta; | |
| velocity.y -= 9.8 * 100.0 * delta; | |
| direction.z = Number(moveForward) - Number(moveBackward); | |
| direction.x = Number(moveRight) - Number(moveLeft); | |
| direction.normalize(); | |
| if (moveForward || moveBackward) velocity.z -= direction.z * 400.0 * delta; | |
| if (moveLeft || moveRight) velocity.x -= direction.x * 400.0 * delta; | |
| if (isMobile) { | |
| camera.translateX(velocity.x * delta); | |
| camera.translateZ(velocity.z * delta); | |
| } else { | |
| controls.moveRight(-velocity.x * delta); | |
| controls.moveForward(-velocity.z * delta); | |
| } | |
| if (Math.abs(velocity.x) > 0.1 || Math.abs(velocity.z) > 0.1) { | |
| isMoving = true; | |
| } | |
| if (isMobile) { | |
| camera.position.y += (velocity.y * delta); | |
| if (camera.position.y < 1.6) { | |
| velocity.y = 0; | |
| camera.position.y = 1.6; | |
| canJump = true; | |
| } | |
| } else { | |
| controls.getObject().position.y += (velocity.y * delta); | |
| if (controls.getObject().position.y < 1.6) { | |
| velocity.y = 0; | |
| controls.getObject().position.y = 1.6; | |
| canJump = true; | |
| } | |
| } | |
| checkHotspots(); | |
| } | |
| prevTime = time; | |
| for (const id in players) { | |
| if (players[id].mesh && players[id].target) { | |
| players[id].mesh.position.lerp(players[id].target.pos, 0.1); | |
| players[id].mesh.quaternion.slerp(players[id].target.quat, 0.1); | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| function checkHotspots() { | |
| let playerPos = isMobile ? camera.position : controls.getObject().position; | |
| let closestHotspot = null; | |
| let minDistance = 2; | |
| hotspots.forEach(hotspot => { | |
| const distance = playerPos.distanceTo(hotspot.pos); | |
| if (distance < minDistance) { | |
| closestHotspot = hotspot; | |
| minDistance = distance; | |
| } | |
| }); | |
| const prompt = document.getElementById('hotspot-prompt'); | |
| if (closestHotspot) { | |
| prompt.style.display = 'block'; | |
| prompt.textContent = `Нажмите [E] чтобы: ${closestHotspot.name}`; | |
| currentHotspot = closestHotspot; | |
| } else { | |
| prompt.style.display = 'none'; | |
| currentHotspot = null; | |
| } | |
| } | |
| function interactWithHotspot() { | |
| if (!currentHotspot) return; | |
| if(!isMobile) controls.unlock(); | |
| switch(currentHotspot.type) { | |
| case 'youtube': | |
| openYoutubeModal(currentHotspot.data); | |
| break; | |
| case 'info': | |
| openInfoModal(currentHotspot.data); | |
| break; | |
| case 'game': | |
| if (isAdmin) { | |
| openGameModal(currentHotspot.data); | |
| } else { | |
| openInfoModal('Только администратор может начать игру.'); | |
| } | |
| break; | |
| } | |
| } | |
| function openYoutubeModal(videoId) { | |
| document.getElementById('youtube-modal').style.display = 'flex'; | |
| if (youtubePlayer) { | |
| youtubePlayer.loadVideoById(videoId); | |
| } else { | |
| youtubePlayer = new YT.Player('youtube-player-container', { | |
| height: '100%', | |
| width: '100%', | |
| videoId: videoId, | |
| events: { 'onReady': (e) => e.target.playVideo() } | |
| }); | |
| } | |
| } | |
| function closeYoutubeModal() { | |
| document.getElementById('youtube-modal').style.display = 'none'; | |
| if (youtubePlayer && typeof youtubePlayer.stopVideo === 'function') { | |
| youtubePlayer.stopVideo(); | |
| } | |
| } | |
| function openInfoModal(text) { | |
| document.getElementById('info-text').textContent = text; | |
| document.getElementById('info-modal').style.display = 'flex'; | |
| } | |
| function closeInfoModal() { | |
| document.getElementById('info-modal').style.display = 'none'; | |
| } | |
| function openGameModal(gameId) { | |
| document.getElementById('game-modal').style.display = 'flex'; | |
| startGame(gameId); | |
| } | |
| function closeGameModal() { | |
| document.getElementById('game-modal').style.display = 'none'; | |
| } | |
| setInterval(() => { | |
| if (isMobile || (controls && controls.isLocked)) { | |
| let playerPos = isMobile ? camera.position : controls.getObject().position; | |
| let playerRot = isMobile ? camera.quaternion : controls.getObject().quaternion; | |
| socket.emit('player_move', { | |
| token: token, | |
| username: username, | |
| pos: playerPos.toArray(), | |
| quat: playerRot.toArray() | |
| }); | |
| } | |
| }, 100); | |
| function addPlayer(id) { | |
| if (players[id] || id === username) return; | |
| const playerGroup = new THREE.Group(); | |
| const bodyMaterial = new THREE.MeshStandardMaterial({ color: Math.random() * 0xffffff }); | |
| const body = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.2, 0.5), bodyMaterial); | |
| body.position.y = 0.6; | |
| const headMaterial = new THREE.MeshStandardMaterial({ color: 0xffdbac }); | |
| const head = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.4, 0.4), headMaterial); | |
| head.position.y = 1.4; | |
| const nameCanvas = document.createElement('canvas'); | |
| const context = nameCanvas.getContext('2d'); | |
| context.font = "Bold 40px Arial"; | |
| context.fillStyle = "rgba(255,255,255,0.95)"; | |
| context.fillText(id, 0, 40); | |
| const nameTexture = new THREE.CanvasTexture(nameCanvas); | |
| const nameSpriteMaterial = new THREE.SpriteMaterial({ map: nameTexture }); | |
| const nameSprite = new THREE.Sprite(nameSpriteMaterial); | |
| nameSprite.position.y = 2.0; | |
| nameSprite.scale.set(2, 1, 1); | |
| playerGroup.add(body); | |
| playerGroup.add(head); | |
| playerGroup.add(nameSprite); | |
| scene.add(playerGroup); | |
| players[id] = { mesh: playerGroup, target: { pos: new THREE.Vector3(), quat: new THREE.Quaternion() } }; | |
| } | |
| function removePlayer(id) { | |
| if (players[id]) { | |
| scene.remove(players[id].mesh); | |
| delete players[id]; | |
| } | |
| } | |
| socket.on('connect', () => { | |
| console.log('Connected to server!'); | |
| if (username || is_guest) { | |
| socket.emit('join', { token, username, is_guest }); | |
| } | |
| }); | |
| socket.on('player_moved', (data) => { | |
| if (data.username === username) return; | |
| if (!players[data.username]) { | |
| addPlayer(data.username); | |
| } | |
| if(players[data.username]) { | |
| players[data.username].target.pos.fromArray(data.pos); | |
| players[data.username].target.quat.fromArray(data.quat); | |
| } | |
| }); | |
| socket.on('init_users', (data) => { | |
| const allUsers = [...data.users, ...data.guests]; | |
| allUsers.forEach(user => addPlayer(user)); | |
| }); | |
| socket.on('user_joined', (data) => { | |
| addPlayer(data.username); | |
| }); | |
| socket.on('user_left', (data) => { | |
| removePlayer(data.username); | |
| }); | |
| navigator.mediaDevices.getUserMedia({ video: true, audio: true }) | |
| .then(stream => { | |
| localStream = stream; | |
| addVideoStream(stream, username, true); | |
| }) | |
| .catch(err => { | |
| console.error("Ошибка доступа к медиаустройствам:", err); | |
| }); | |
| function addVideoStream(stream, user, isLocal = false) { | |
| const videoGrid = document.getElementById('video-grid'); | |
| const videoContainer = document.createElement('div'); | |
| videoContainer.classList.add('video-container'); | |
| const video = document.createElement('video'); | |
| video.srcObject = stream; | |
| video.dataset.user = user; | |
| video.setAttribute('playsinline', ''); | |
| video.setAttribute('autoplay', ''); | |
| if (isLocal) video.muted = true; | |
| const nameTag = document.createElement('div'); | |
| nameTag.classList.add('user-indicator'); | |
| nameTag.textContent = user; | |
| videoContainer.appendChild(video); | |
| videoContainer.appendChild(nameTag); | |
| videoGrid.appendChild(videoContainer); | |
| video.play(); | |
| } | |
| socket.on('signal', data => { | |
| if (data.from === username) return; | |
| handleSignal(data.from, data.signal); | |
| }); | |
| function handleSignal(from, signal) { | |
| if (!peers[from]) { | |
| createPeerConnection(from, false); | |
| } | |
| peers[from].signal(signal); | |
| } | |
| function createPeerConnection(remoteUser, initiator) { | |
| const peer = new SimplePeer({ | |
| initiator: initiator, | |
| trickle: false, | |
| stream: localStream, | |
| config: iceConfig | |
| }); | |
| peer.on('signal', signal => { | |
| socket.emit('signal', { to: remoteUser, from: username, token, signal }); | |
| }); | |
| peer.on('stream', stream => { | |
| addVideoStream(stream, remoteUser); | |
| }); | |
| peer.on('close', () => { | |
| const videoElement = document.querySelector(`video[data-user="${remoteUser}"]`); | |
| if (videoElement) videoElement.parentElement.remove(); | |
| delete peers[remoteUser]; | |
| }); | |
| peers[remoteUser] = peer; | |
| } | |
| function leaveRoom() { | |
| socket.emit('leave', { token, username, is_guest }); | |
| window.location.href = is_guest ? '/guest_login/' + token : '/dashboard'; | |
| } | |
| window.leaveRoom = leaveRoom; | |
| // Game Logic Integration | |
| function startGame(gameId) { | |
| console.log('Starting game:', gameId); | |
| document.getElementById('game-display').style.display = 'block'; | |
| document.getElementById('game-content').innerHTML = ''; | |
| socket.emit('start_game', { token, game_id: gameId }); | |
| } | |
| window.startGame = startGame; | |
| socket.on('game_started', (data) => { | |
| const gameId = data.game_id; | |
| const gameInfo = {{ games_data|tojson }}[gameId]; | |
| const gameDisplay = document.getElementById('game-display'); | |
| gameDisplay.style.display = 'block'; | |
| document.getElementById('game-description').innerText = gameInfo.description; | |
| gameDisplay.querySelector('h2').innerText = gameInfo.name; | |
| const gameContent = document.getElementById('game-content'); | |
| gameContent.innerHTML = ''; | |
| if (gameId === 'crocodile' || gameId === 'alias'){ | |
| initWordGame(gameId, gameContent); | |
| } else if(gameId === 'mafia'){ | |
| initMafia(gameContent, gameInfo); | |
| } | |
| }); | |
| function initWordGame(gameId, gameContent) { | |
| let elements = ` | |
| <input type="text" id="${gameId}-word-input" placeholder="Введите слово (только админ)" class="game-input"> | |
| <button id="start-turn-button" class="game-button">Начать ход</button> | |
| <input type="text" id="${gameId}-guess-input" placeholder="Ваша догадка" class="game-input"> | |
| <button id="${gameId}-guess-button" class="game-button">Угадать</button> | |
| <div id="${gameId}-result"></div> | |
| <div id="${gameId}-timer"></div> | |
| `; | |
| gameContent.innerHTML = elements; | |
| const wordInput = document.getElementById(`${gameId}-word-input`); | |
| const startTurnButton = document.getElementById('start-turn-button'); | |
| const guessInput = document.getElementById(`${gameId}-guess-input`); | |
| const guessButton = document.getElementById(`${gameId}-guess-button`); | |
| wordInput.style.display = isAdmin ? 'block' : 'none'; | |
| startTurnButton.style.display = isAdmin ? 'block' : 'none'; | |
| startTurnButton.onclick = () => { | |
| const word = wordInput.value.trim(); | |
| if (word) { | |
| socket.emit('set_game_state', {token, game_id: gameId, state: { word: word, presenter: null, guesses: [], timer: 60, isRunning: true }}); | |
| wordInput.value = ''; | |
| } | |
| }; | |
| guessButton.onclick = () => { | |
| const guess = guessInput.value.trim(); | |
| if (guess) { | |
| socket.emit('game_action', { token, game_id: gameId, action: 'guess', value: guess, user: username }); | |
| guessInput.value = ''; | |
| } | |
| }; | |
| } | |
| function initMafia(gameContent, gameInfo) { | |
| let elements = ` | |
| <button id="start-mafia-button" class="game-button">Начать игру (только админ)</button> | |
| <div id="mafia-result"></div> | |
| <input type="text" id="mafia-vote-input" placeholder="За кого голосуете?" class="game-input"> | |
| <button id="mafia-vote-button" class="game-button">Голосовать</button> | |
| `; | |
| gameContent.innerHTML = elements; | |
| const startButton = document.getElementById('start-mafia-button'); | |
| const voteInput = document.getElementById('mafia-vote-input'); | |
| const voteButton = document.getElementById('mafia-vote-button'); | |
| startButton.style.display = isAdmin ? 'block' : 'none'; | |
| startButton.onclick = () => socket.emit('set_game_state', { token, game_id: 'mafia', state: { roles: {}, phase: 'night', votes: {}, isRunning: true, killed: null } }); | |
| voteButton.onclick = () => { | |
| const vote = voteInput.value.trim(); | |
| if (vote) { | |
| socket.emit('game_action', { token, game_id: 'mafia', action: 'vote', value: vote, user: username }); | |
| voteInput.value = ''; | |
| } | |
| }; | |
| } | |
| socket.on('update_game_state', (data) => { | |
| const gameId = data.game_id; | |
| const gameState = data.state; | |
| if (gameId === 'crocodile' || gameId === 'alias'){ | |
| updateWordGameState(gameId, gameState); | |
| } else if (gameId === 'mafia') { | |
| updateMafiaState(gameState); | |
| } | |
| }); | |
| function updateWordGameState(gameId, gameState) { | |
| const resultDiv = document.getElementById(`${gameId}-result`); | |
| const timerDiv = document.getElementById(`${gameId}-timer`); | |
| const guessInput = document.getElementById(`${gameId}-guess-input`); | |
| const guessButton = document.getElementById(`${gameId}-guess-button`); | |
| if (!resultDiv) return; | |
| resultDiv.innerHTML = ''; | |
| if(gameState.presenter) { | |
| resultDiv.innerHTML += `<p>Ведущий: ${gameState.presenter}</p>`; | |
| } | |
| if (username === gameState.presenter) { | |
| resultDiv.innerHTML += `<p>Ваше слово: <strong>${gameState.word}</strong></p>`; | |
| } | |
| gameState.guesses.forEach(g => { | |
| resultDiv.innerHTML += `<p>${g.user}: ${g.value} (${g.result})</p>`; | |
| }); | |
| timerDiv.textContent = `Время: ${gameState.timer}`; | |
| const isGameOver = !gameState.isRunning || gameState.timer <= 0; | |
| guessInput.disabled = isGameOver || username === gameState.presenter; | |
| guessButton.disabled = isGameOver || username === gameState.presenter; | |
| if (isGameOver && gameState.word) { | |
| resultDiv.innerHTML += `<p>Игра окончена! Загаданное слово было: <strong>${gameState.word}</strong></p>`; | |
| } | |
| } | |
| function updateMafiaState(gameState) { | |
| const resultDiv = document.getElementById('mafia-result'); | |
| const voteInput = document.getElementById('mafia-vote-input'); | |
| const voteButton = document.getElementById('mafia-vote-button'); | |
| if (!resultDiv) return; | |
| resultDiv.innerHTML = ''; | |
| if (gameState.roles && gameState.roles[username]) { | |
| resultDiv.innerHTML += `<p>Ваша роль: ${gameState.roles[username]}</p>`; | |
| } | |
| resultDiv.innerHTML += `<p>Фаза: ${gameState.phase === 'day' ? 'День' : 'Ночь'}</p>`; | |
| if (gameState.phase === 'day') { | |
| if (gameState.killed) { | |
| resultDiv.innerHTML += `<p>Ночью был убит: ${gameState.killed}</p>`; | |
| } | |
| resultDiv.innerHTML += `<p>Идет голосование. Живые игроки: ${gameState.players.join(', ')}</p>`; | |
| voteInput.disabled = false; | |
| voteButton.disabled = false; | |
| } else { | |
| resultDiv.innerHTML += `<p>Мафия выбирает жертву. Мирные спят.</p>`; | |
| voteInput.disabled = true; | |
| voteButton.disabled = true; | |
| } | |
| if (gameState.winner) { | |
| resultDiv.innerHTML += `<h2>Игра окончена! Победили: ${gameState.winner}</h2>`; | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| ''', token=token, session=session, is_admin=is_admin, games_data=games_data, username=username, is_guest=is_guest) | |
| def join_as_guest(token): | |
| if token not in rooms: | |
| return "Комната не найдена", 404 | |
| guest_id = 'Гость_' + ''.join(random.choices(string.digits, k=4)) | |
| session['guest_id'] = guest_id | |
| if 'username' in session: | |
| session.pop('username') | |
| return redirect(url_for('room', token=token)) | |
| def guest_login(token): | |
| if token not in rooms: | |
| return "Комната не найдена", 404 | |
| return render_template_string(''' | |
| <!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Вход для гостей</title> | |
| <style> | |
| :root { | |
| --primary-color: #4CAF50; | |
| --background-color: #f0f0f0; | |
| --surface-color: #ffffff; | |
| --text-color: #333333; | |
| --border-radius: 12px; | |
| --box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: var(--background-color); color: var(--text-color); } | |
| .container { text-align: center; padding: 30px; border: 1px solid #ccc; border-radius: var(--border-radius); background-color: var(--surface-color); box-shadow: var(--box-shadow); } | |
| h1 { color: var(--primary-color); margin-bottom: 20px; } | |
| p { margin-bottom: 20px; } | |
| a { padding: 12px 25px; background-color: var(--primary-color); color: white; text-decoration: none; border-radius: 5px; font-size: 1.1rem; transition: background-color 0.3s ease; } | |
| a:hover { background-color: #388E3C; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Вход в комнату как Гость</h1> | |
| <p>Вы можете войти в комнату <strong>{{ token }}</strong> как гость. Ваше имя будет автоматически сгенерировано.</p> | |
| <a href="{{ url_for('join_as_guest', token=token) }}">Войти в комнату как гость</a> | |
| <p style="margin-top: 20px;"><a href="{{ url_for('index') }}">Или войти/зарегистрироваться</a></p> | |
| </div> | |
| </body> | |
| </html> | |
| ''', token=token) | |
| def handle_connect(): | |
| print(f"Client connected: {request.sid}") | |
| def handle_disconnect_custom(): | |
| print(f"Client disconnected: {request.sid}") | |
| def handle_join(data): | |
| token = data['token'] | |
| username = data['username'] | |
| join_room(token) | |
| if token not in rooms: | |
| return | |
| all_users = rooms[token]['users'] + rooms[token]['guests'] | |
| emit('init_users', {'users': rooms[token]['users'], 'guests': rooms[token]['guests']}, to=request.sid) | |
| emit('user_joined', { | |
| 'username': username, | |
| 'users': rooms[token]['users'], | |
| 'guests': rooms[token]['guests'] | |
| }, room=token, include_self=False) | |
| if rooms[token]['current_game']: | |
| game_id = rooms[token]['current_game'] | |
| emit('game_started', {'game_id': game_id}, to=request.sid) | |
| if token in games_data.get(game_id, {}).get('state', {}): | |
| emit('update_game_state', {'game_id': game_id, 'state': games_data[game_id]['state'][token]}, to=request.sid) | |
| def handle_leave(data): | |
| token = data['token'] | |
| username = data['username'] | |
| is_guest = data.get('is_guest', False) | |
| leave_room(token) | |
| if token in rooms: | |
| if is_guest: | |
| if username in rooms[token]['guests']: | |
| rooms[token]['guests'].remove(username) | |
| else: | |
| if username in rooms[token]['users']: | |
| rooms[token]['users'].remove(username) | |
| if not rooms[token]['users'] and not rooms[token]['guests']: | |
| del rooms[token] | |
| else: | |
| emit('user_left', {'username': username}, room=token) | |
| save_json(ROOMS_DB, rooms) | |
| def handle_player_move(data): | |
| token = data['token'] | |
| if token in rooms: | |
| emit('player_moved', { | |
| 'username': data['username'], | |
| 'pos': data['pos'], | |
| 'quat': data['quat'] | |
| }, room=token, include_self=False) | |
| def handle_signal(data): | |
| if data['token'] in rooms: | |
| emit('signal', { | |
| 'from': data['from'], | |
| 'signal': data['signal'] | |
| }, room=data['to']) | |
| def handle_start_game(data): | |
| token = data['token'] | |
| game_id = data['game_id'] | |
| username = session.get('username') | |
| if token in rooms and rooms[token].get('admin') == username: | |
| all_users_in_room = rooms[token]['users'] + rooms[token]['guests'] | |
| min_p = games_data[game_id]['min_players'] | |
| max_p = games_data[game_id]['max_players'] | |
| if not (min_p <= len(all_users_in_room) <= max_p): | |
| return | |
| rooms[token]['current_game'] = game_id | |
| if game_id not in games_data: games_data[game_id] = {'state': {}} | |
| elif 'state' not in games_data[game_id]: games_data[game_id]['state'] = {} | |
| games_data[game_id]['state'][token] = {} | |
| save_json(ROOMS_DB, rooms) | |
| save_json(GAMES_DB, games_data) | |
| emit('game_started', {'game_id': game_id}, room=token) | |
| def handle_set_game_state(data): | |
| token = data['token'] | |
| game_id = data['game_id'] | |
| state = data['state'] | |
| username = session.get('username') | |
| if token in rooms and rooms[token].get('admin') == username: | |
| if rooms[token]['current_game'] == game_id: | |
| all_users = rooms[token]['users'] + rooms[token]['guests'] | |
| state['players'] = all_users | |
| if (game_id == 'crocodile' or game_id == 'alias') and 'presenter' not in state: | |
| state['presenter'] = random.choice(all_users) if all_users else None | |
| if game_id == 'mafia' and not state.get('roles'): | |
| state['roles'] = assign_mafia_roles(all_users) | |
| games_data[game_id]['state'][token] = state | |
| save_json(GAMES_DB, games_data) | |
| emit('update_game_state', {'game_id': game_id, 'state': state}, room=token) | |
| if state.get('isRunning') and (game_id == 'crocodile' or game_id == 'alias'): | |
| start_timer(token, game_id) | |
| def assign_mafia_roles(users): | |
| num_players = len(users) | |
| num_mafia = 1 if num_players < 6 else 2 | |
| roles_list = ['mafia'] * num_mafia + ['civilian'] * (num_players - num_mafia) | |
| random.shuffle(roles_list) | |
| return {user: role for user, role in zip(users, roles_list)} | |
| def handle_game_action(data): | |
| token = data['token'] | |
| game_id = data['game_id'] | |
| action = data['action'] | |
| user = data['user'] | |
| value = data.get('value') | |
| if token not in rooms or game_id != rooms[token].get('current_game'): return | |
| current_state = games_data[game_id]['state'].get(token, {}) | |
| if not current_state.get('isRunning'): return | |
| if game_id in ['crocodile', 'alias'] and action == 'guess': | |
| if user == current_state.get('presenter'): return | |
| result = "Угадано!" if value.lower() == current_state.get('word', '').lower() else "Не угадано" | |
| current_state.setdefault('guesses', []).append({'user': user, 'value': value, 'result': result}) | |
| if result == "Угадано!": current_state['isRunning'] = False | |
| elif game_id == 'mafia' and action == 'vote': | |
| if current_state['phase'] == 'day': | |
| current_state.setdefault('votes', {})[user] = value | |
| all_players = current_state.get('players', []) | |
| if len(current_state['votes']) == len(all_players): | |
| process_mafia_votes(current_state) | |
| games_data[game_id]['state'][token] = current_state | |
| save_json(GAMES_DB, games_data) | |
| emit('update_game_state', {'game_id': game_id, 'state': current_state}, room=token) | |
| def process_mafia_votes(state): | |
| votes = state.get('votes', {}) | |
| counts = {} | |
| for vote in votes.values(): counts[vote] = counts.get(vote, 0) + 1 | |
| max_votes = 0 | |
| voted_out = [] | |
| for player, num_votes in counts.items(): | |
| if num_votes > max_votes: | |
| max_votes = num_votes | |
| voted_out = [player] | |
| elif num_votes == max_votes: | |
| voted_out.append(player) | |
| if len(voted_out) == 1: | |
| player_to_remove = voted_out[0] | |
| state['players'].remove(player_to_remove) | |
| state['killed'] = player_to_remove | |
| remaining_mafia = sum(1 for p in state['players'] if state['roles'].get(p) == 'mafia') | |
| remaining_civilians = len(state['players']) - remaining_mafia | |
| if remaining_mafia == 0: | |
| state['winner'] = 'Мирные' | |
| state['isRunning'] = False | |
| elif remaining_mafia >= remaining_civilians: | |
| state['winner'] = 'Мафия' | |
| state['isRunning'] = False | |
| state['phase'] = 'night' | |
| state['votes'] = {} | |
| def start_timer(token, game_id): | |
| def timer_loop(): | |
| with app.app_context(): | |
| while True: | |
| state = games_data[game_id]['state'].get(token) | |
| if not state or not state.get('isRunning') or state.get('timer', 0) <= 0: | |
| if state: | |
| state['isRunning'] = False | |
| games_data[game_id]['state'][token] = state | |
| save_json(GAMES_DB, games_data) | |
| socketio.emit('update_game_state', {'game_id': game_id, 'state': state}, room=token) | |
| break | |
| state['timer'] -= 1 | |
| games_data[game_id]['state'][token] = state | |
| save_json(GAMES_DB, games_data) | |
| socketio.emit('update_game_state', {'game_id': game_id, 'state': state}, room=token) | |
| socketio.sleep(1) | |
| socketio.start_background_task(timer_loop) | |
| if __name__ == '__main__': | |
| if not os.path.exists(os.path.join(app.root_path, 'rooms.json')): | |
| with open(os.path.join(app.root_path, 'rooms.json'), 'w') as f: json.dump({}, f) | |
| if not os.path.exists(os.path.join(app.root_path, 'users.json')): | |
| with open(os.path.join(app.root_path, 'users.json'), 'w') as f: json.dump({}, f) | |
| if not os.path.exists(os.path.join(app.root_path, 'games.json')): | |
| save_json(GAMES_DB, games_data) | |
| backup_thread = threading.Thread(target=periodic_backup, daemon=True) | |
| #backup_thread.start() | |
| # ВАЖНО: для работы WebRTC нужен HTTPS. Werkzeug создает самоподписанный сертификат. | |
| # Вам нужно будет разрешить его в браузере (на странице "Дополнительно" -> "Перейти на сайт (небезопасно)"). | |
| # Для продакшена используйте полноценный веб-сервер (Nginx) с настоящими SSL-сертификатами. | |
| try: | |
| socketio.run(app, host='0.0.0.0', port=7860, debug=True, allow_unsafe_werkzeug=True, ssl_context='adhoc') | |
| except TypeError: | |
| socketio.run(app, host='0.0.0.0', port=7860, debug=True, allow_unsafe_werkzeug=True) |