Aleksmorshen commited on
Commit
6290c32
·
verified ·
1 Parent(s): e806447

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +764 -752
app.py CHANGED
@@ -13,140 +13,76 @@ from huggingface_hub.utils import RepositoryNotFoundError
13
  from urllib.parse import urlparse, parse_qs
14
 
15
  app = Flask(__name__)
16
- app.config['SECRET_KEY'] = 'your-very-secret-key-here'
17
  socketio = SocketIO(app, async_mode='threading')
18
 
19
  REPO_ID = "flpolprojects/Clients"
20
- HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
21
- HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
22
 
23
- ROOMS_DB = os.path.join(app.root_path, 'rooms.json')
24
- USERS_DB = os.path.join(app.root_path, 'users.json')
25
- GAMES_DB = os.path.join(app.root_path, 'games.json')
26
 
27
- def load_json(file_path, default={}):
28
  try:
29
- download_db_from_hf()
30
- if os.path.exists(file_path):
31
- with open(file_path, 'r', encoding='utf-8') as f:
32
- return json.load(f)
33
- return default
34
- except (FileNotFoundError, json.JSONDecodeError) as e:
35
- print(f"Ошибка загрузки JSON из {file_path}: {e}")
36
- return default
 
 
 
 
37
  except Exception as e:
38
- print(f"Непредвиденная ошибка при загрузке: {e}")
39
- return default
40
 
41
- def save_json(file_path, data):
42
  try:
43
- with open(file_path, 'w', encoding='utf-8') as f:
44
- json.dump(data, f, indent=4, ensure_ascii=False)
45
- upload_db_to_hf()
46
- except OSError as e:
47
- print(f"Ошибка сохранения JSON в {file_path}: {e}")
48
- except Exception as e:
49
- print(f"Непредвиденная ошибка при сохранении: {e}")
50
 
51
- def upload_db_to_hf():
52
  try:
53
- api = HfApi()
54
- for file_path, repo_path in [(ROOMS_DB, "rooms.json"), (USERS_DB, "users.json"), (GAMES_DB, "games.json")]:
55
- if os.path.exists(file_path):
56
- api.upload_file(
57
- path_or_fileobj=file_path,
58
- path_in_repo=repo_path,
59
- repo_id=REPO_ID,
60
- repo_type="dataset",
61
- token=HF_TOKEN_WRITE,
62
- commit_message=f"Backup: {repo_path} ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})"
63
- )
64
- except Exception as e:
65
- print(f"Ошибка при загрузке файлов на Hugging Face Hub: {e}")
 
 
 
 
 
 
 
 
 
 
66
 
67
- def download_db_from_hf():
68
- try:
69
- api = HfApi()
70
- for file_path, repo_path in [(ROOMS_DB, "rooms.json"), (USERS_DB, "users.json"), (GAMES_DB, "games.json")]:
71
- try:
72
- hf_hub_download(
73
- repo_id=REPO_ID,
74
- filename=repo_path,
75
- repo_type="dataset",
76
- token=HF_TOKEN_READ,
77
- local_dir=".",
78
- local_dir_use_symlinks=False
79
- )
80
- except RepositoryNotFoundError:
81
- if not os.path.exists(file_path):
82
- with open(file_path, 'w') as f:
83
- json.dump({}, f)
84
- except Exception as e:
85
- print(f"Ошибка при скачивании файла {repo_path}: {e}")
86
- except Exception as e:
87
- print(f"Ошибка при скачивании файлов с Hugging Face Hub: {e}")
88
 
89
  def periodic_backup():
90
  while True:
91
- upload_db_to_hf()
92
- time.sleep(15)
93
-
94
- rooms = load_json(ROOMS_DB)
95
- users = load_json(USERS_DB)
96
- games_data = load_json(GAMES_DB, default={
97
- "crocodile": {
98
- "name": "Крокодил",
99
- "description": "Один игрок показывает слово жестами.",
100
- "min_players": 2,
101
- "max_players": 10,
102
- "state": {}
103
- },
104
- "alias": {
105
- "name": "Alias",
106
- "description": "Один игрок объясняет слово.",
107
- "min_players": 2,
108
- "max_players": 10,
109
- "state": {}
110
- },
111
- "mafia": {
112
- "name": "Мафия",
113
- "description": "Мафия против мирных жителей.",
114
- "min_players": 4,
115
- "max_players": 10,
116
- "state": {}
117
- },
118
- "durak": {
119
- "name": "Дурак",
120
- "description": "Карточная игра.",
121
- "min_players": 2,
122
- "max_players": 6,
123
- "state": {}
124
- }
125
- })
126
- save_json(GAMES_DB, games_data)
127
 
128
  def generate_token():
129
- return ''.join(random.choices(string.ascii_letters + string.digits, k=15))
130
 
131
  def hash_password(password):
132
  return hashlib.sha256(password.encode('utf-8')).hexdigest()
133
 
134
- def get_youtube_id(url):
135
- if not url:
136
- return None
137
- parsed_url = urlparse(url)
138
- if parsed_url.hostname in ('www.youtube.com', 'youtube.com'):
139
- if parsed_url.path == '/watch':
140
- query = parse_qs(parsed_url.query)
141
- return query.get('v', [None])[0]
142
- elif parsed_url.path.startswith('/embed/'):
143
- return parsed_url.path.split('/embed/')[1].split('?')[0]
144
- elif parsed_url.path.startswith('/v/'):
145
- return parsed_url.path.split('/v/')[1].split('?')[0]
146
- elif parsed_url.hostname in ('youtu.be', 'www.youtu.be'):
147
- return parsed_url.path[1:].split('?')[0]
148
- return None
149
-
150
  @app.route('/', methods=['GET', 'POST'])
151
  def index():
152
  if 'username' in session:
@@ -158,15 +94,16 @@ def index():
158
  password = request.form.get('password')
159
 
160
  if action == 'register':
161
- if username in users:
162
  return "Пользователь уже существует", 400
163
- users[username] = {'password': hash_password(password), 'rooms': []}
164
- save_json(USERS_DB, users)
165
  session['username'] = username
166
  return redirect(url_for('dashboard'))
167
 
168
  elif action == 'login':
169
- if username in users and users[username]['password'] == hash_password(password):
 
170
  session['username'] = username
171
  return redirect(url_for('dashboard'))
172
  return "Неверный логин или пароль", 401
@@ -177,25 +114,22 @@ def index():
177
  <head>
178
  <meta charset="UTF-8">
179
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
180
- <title>Room</title>
181
  <style>
182
  :root {
183
- --primary-color: #6200ee;
184
- --secondary-color: #3700b3;
185
- --background-color: #ffffff;
186
- --surface-color: #f5f5f5;
187
- --text-color: #333333;
188
- --error-color: #b00020;
189
  --font-family: 'Roboto', sans-serif;
190
  --border-radius: 12px;
191
- --box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
192
  }
193
  body {
194
  font-family: var(--font-family);
195
  background-color: var(--background-color);
196
  color: var(--text-color);
197
  margin: 0;
198
- padding: 0;
199
  display: flex;
200
  justify-content: center;
201
  align-items: center;
@@ -205,107 +139,88 @@ def index():
205
  background-color: var(--surface-color);
206
  padding: 2rem;
207
  border-radius: var(--border-radius);
208
- box-shadow: var(--box-shadow);
209
  width: 90%;
210
  max-width: 400px;
211
  text-align: center;
212
  }
213
  h1 {
214
- font-size: 2rem;
215
  margin-bottom: 1.5rem;
216
  color: var(--primary-color);
 
217
  }
218
  input, button {
219
  display: block;
220
  width: 100%;
221
- padding: 0.75rem;
222
- margin-bottom: 1rem;
223
- border: 1px solid #ccc;
 
 
224
  border-radius: var(--border-radius);
225
  font-size: 1rem;
226
  box-sizing: border-box;
227
- transition: border-color 0.3s ease;
228
  }
229
  input:focus {
230
  outline: none;
231
  border-color: var(--primary-color);
 
232
  }
233
  button {
234
  background-color: var(--primary-color);
235
- color: white;
236
  cursor: pointer;
237
  border: none;
238
  font-weight: 500;
239
- transition: background-color 0.3s ease;
240
  }
241
  button:hover {
242
  background-color: var(--secondary-color);
243
- }
244
- button:active {
245
- opacity: 0.8;
246
- }
247
- .error-message {
248
- color: var(--error-color);
249
- margin-top: 0.5rem;
250
- }
251
- @media (prefers-color-scheme: dark) {
252
- :root {
253
- --background-color: #121212;
254
- --surface-color: #1e1e1e;
255
- --text-color: #ffffff;
256
- }
257
  }
258
  </style>
259
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
260
  </head>
261
  <body>
262
  <div class="container">
263
- <h1>Room</h1>
264
  <form method="post">
265
  <input type="text" name="username" placeholder="Логин" required>
266
  <input type="password" name="password" placeholder="Пароль" required>
267
  <button type="submit" name="action" value="login">Войти</button>
268
- <button type="submit" name="action" value="register">Зарегистрироваться</button>
269
  </form>
270
  </div>
271
  </body>
272
  </html>
273
  ''')
274
 
275
-
276
  @app.route('/dashboard', methods=['GET', 'POST'])
277
  def dashboard():
278
  if 'username' not in session:
279
  return redirect(url_for('index'))
280
 
281
- user = users.get(session['username'])
282
- if not user:
283
- session.pop('username', None)
284
- return redirect(url_for('index'))
285
-
286
  if request.method == 'POST':
287
  action = request.form.get('action')
288
  if action == 'create':
289
  token = generate_token()
290
- rooms[token] = {
291
- 'players': {},
292
  'admin': session['username'],
293
- 'current_game': None,
294
- 'youtube_url': None,
295
- 'youtube_state': {'isPlaying': False, 'currentTime': 0, 'last_sync_time': time.time()}
 
 
 
296
  }
297
- users[session['username']]['rooms'].append(token)
298
- save_json(ROOMS_DB, rooms)
299
- save_json(USERS_DB, users)
300
- return redirect(url_for('room', token=token))
301
  elif action == 'join':
302
  token = request.form.get('token')
303
- if token in rooms:
304
- if session['username'] not in [p['username'] for p in rooms[token]['players'].values()]:
305
- users[session['username']]['rooms'].append(token)
306
- save_json(USERS_DB, users)
307
- return redirect(url_for('room', token=token))
308
- return "Комната не найдена", 404
309
 
310
  return render_template_string('''
311
  <!DOCTYPE html>
@@ -316,22 +231,21 @@ def dashboard():
316
  <title>Панель управления</title>
317
  <style>
318
  :root {
319
- --primary-color: #6200ee;
320
- --secondary-color: #3700b3;
321
- --background-color: #ffffff;
322
- --surface-color: #f5f5f5;
323
- --text-color: #333333;
324
- --accent-color: #03dac6;
325
  --font-family: 'Roboto', sans-serif;
326
  --border-radius: 12px;
327
- --box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
328
  }
329
  body {
330
  font-family: var(--font-family);
331
  background-color: var(--background-color);
332
  color: var(--text-color);
333
  margin: 0;
334
- padding: 0;
335
  display: flex;
336
  flex-direction: column;
337
  align-items: center;
@@ -341,11 +255,10 @@ def dashboard():
341
  background-color: var(--surface-color);
342
  padding: 2rem;
343
  border-radius: var(--border-radius);
344
- box-shadow: var(--box-shadow);
345
  width: 90%;
346
- max-width: 400px;
347
  text-align: center;
348
- margin-top: 2rem;
349
  }
350
  h1 {
351
  font-size: 2rem;
@@ -355,46 +268,36 @@ def dashboard():
355
  input, button {
356
  display: block;
357
  width: 100%;
358
- padding: 0.75rem;
359
  margin-bottom: 1rem;
360
- border: 1px solid #ccc;
 
 
361
  border-radius: var(--border-radius);
362
  font-size: 1rem;
363
  box-sizing: border-box;
364
- transition: border-color 0.3s ease;
365
  }
366
  input:focus {
367
  outline: none;
368
  border-color: var(--primary-color);
 
369
  }
370
  button {
371
  background-color: var(--primary-color);
372
- color: white;
373
  cursor: pointer;
374
  border: none;
375
  font-weight: 500;
376
- transition: background-color 0.3s ease;
377
  }
378
  button:hover {
379
  background-color: var(--secondary-color);
380
- }
381
- button:active {
382
- opacity: 0.8;
383
  }
384
  .logout-button {
385
  background-color: var(--accent-color);
386
- margin-top: 1rem;
387
- transition: background-color 0.3s ease;
388
  }
389
  .logout-button:hover {
390
- filter: brightness(0.9);
391
- }
392
- @media (prefers-color-scheme: dark) {
393
- :root {
394
- --background-color: #121212;
395
- --surface-color: #1e1e1e;
396
- --text-color: #ffffff;
397
- }
398
  }
399
  </style>
400
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
@@ -403,673 +306,782 @@ def dashboard():
403
  <div class="container">
404
  <h1>Добро пожаловать, {{ session['username'] }}</h1>
405
  <form method="post">
406
- <button type="submit" name="action" value="create">Создать комнату</button>
407
  </form>
 
408
  <form method="post">
409
- <input type="text" name="token" placeholder="Введите токен комнаты" required>
410
- <button type="submit" name="action" value="join">Войти в комнату</button>
411
  </form>
412
- <form action="/logout" method="post">
413
  <button class="logout-button" type="submit">Выйти</button>
414
  </form>
415
  </div>
416
  </body>
417
  </html>
418
- ''', session=session)
419
 
420
  @app.route('/logout', methods=['POST'])
421
  def logout():
422
  session.pop('username', None)
423
  return redirect(url_for('index'))
424
 
425
- @app.route('/room/<token>')
426
- def room(token):
427
- username = session.get('username') or session.get('guest_id')
428
- if not username:
429
- return redirect(url_for('guest_login', token=token))
430
-
431
- if token not in rooms:
432
  return redirect(url_for('dashboard'))
433
 
434
- is_guest = 'guest_id' in session
435
- is_admin = not is_guest and rooms[token]['admin'] == username
436
 
437
  return render_template_string('''
438
  <!DOCTYPE html>
439
  <html lang="ru">
440
  <head>
441
  <meta charset="UTF-8">
442
- <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
443
- <title>Метавселенная - Комната {{ token }}</title>
444
- <style>
445
- :root {
446
- --primary-color: #4CAF50;
447
- --secondary-color: #388E3C;
448
- --background-color: #121212;
449
- --surface-color: rgba(30, 30, 30, 0.9);
450
- --text-color: #ffffff;
451
- --font-family: 'Roboto', sans-serif;
452
- --border-radius: 12px;
453
- --box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
454
- }
455
- body { margin: 0; overflow: hidden; font-family: var(--font-family); }
456
- .hud { position: fixed; top: 0; left: 0; width: 100%; z-index: 10; padding: 10px; box-sizing: border-box; display: flex; justify-content: space-between; align-items: flex-start; pointer-events: none; }
457
- .hud-left, .hud-right { display: flex; flex-direction: column; gap: 10px; }
458
- .hud-button, .copy-link-button {
459
- background-color: var(--surface-color); color: var(--text-color); border: 1px solid var(--primary-color);
460
- border-radius: var(--border-radius); padding: 8px 12px; cursor: pointer;
461
- font-size: 0.9rem; transition: all 0.2s; pointer-events: all;
462
  }
463
- .hud-button:hover, .copy-link-button:hover { background-color: var(--primary-color); }
464
- #admin-panel { background-color: var(--surface-color); padding: 15px; border-radius: var(--border-radius); display: none; flex-direction: column; gap: 10px; }
465
- #admin-panel input { width: calc(100% - 20px); }
466
- .game-ui-overlay {
467
- position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
468
- background-color: var(--surface-color); color: var(--text-color); padding: 20px; border-radius: var(--border-radius);
469
- box-shadow: var(--box-shadow); z-index: 100; display: none;
470
- width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; text-align: center;
471
- }
472
- #game-display h2 { color: var(--primary-color); }
473
- #game-content { display: flex; flex-direction: column; align-items: center; gap: 10px; }
474
- .game-input { padding: 8px; border: 1px solid #ccc; border-radius: var(--border-radius); font-size: 1rem; width: 80%; }
475
- .game-button { background-color: var(--primary-color); color: white; border: none; padding: 10px 15px; cursor: pointer; transition: background-color 0.2s; font-size: 1rem; }
476
- .game-button:hover { background-color: var(--secondary-color); }
477
- .card { width: 60px; height: 90px; border: 1px solid black; border-radius: 5px; display: inline-block; margin: 2px; text-align: center; font-size: 1rem; background-color: white; user-select: none; color: black; }
478
- #youtube-player-container {
479
- position: fixed; bottom: 10px; left: 10px; width: 320px; height: 180px;
480
- border-radius: var(--border-radius); overflow: hidden; z-index: 20; display: none;
 
 
 
 
 
 
 
481
  }
482
- #youtube-player { width: 100%; height: 100%; }
483
  </style>
484
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
485
- <script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
486
- <script src="https://cdn.jsdelivr.net/gh/c-frame/aframe-extras@7.2.0/dist/aframe-extras.min.js"></script>
487
- <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.js"></script>
488
- <script src="https://www.youtube.com/iframe_api"></script>
489
  </head>
490
  <body>
491
- <div class="hud">
492
- <div class="hud-left">
493
- <button class="hud-button" onclick="document.querySelector('a-scene').exitVR()">Выйти из VR</button>
494
- <button class="copy-link-button" onclick="copyRoomLink()">Скопировать ссылку</button>
 
495
  </div>
496
- <div class="hud-right">
497
- {% if is_admin %}
498
- <button class="hud-button" onclick="toggleAdminPanel()">Панель админа</button>
499
- <div id="admin-panel">
500
- <h4>YouTube</h4>
501
- <input type="text" id="youtube-url-input" placeholder="Ссылка на YouTube видео">
502
- <button class="hud-button" onclick="setYoutubeUrl()">Загрузить</button>
503
- <h4>Игры</h4>
504
- <div id="games-list">
505
- {% for game_id, game_info in games_data.items() %}
506
- <button class="game-button" onclick="startGame('{{ game_id }}')">{{ game_info.name }}</button>
507
- {% endfor %}
508
- </div>
 
 
 
509
  </div>
510
- {% endif %}
 
 
 
 
511
  </div>
512
  </div>
 
 
 
513
 
514
- <div id="game-display" class="game-ui-overlay">
515
- <h2></h2>
516
- <p id="game-description"></p>
517
- <div id="game-content"></div>
518
- </div>
519
-
520
- <div id="youtube-player-container">
521
- <div id="youtube-player"></div>
522
- </div>
523
-
524
- <a-scene background="color: #ECECEC" renderer="colorManagement: true">
525
- <a-assets>
526
- <video id="local-video" autoplay playsinline muted style="display:none"></video>
527
- </a-assets>
528
-
529
- <a-entity id="player" position="0 1.6 0" movement-controls="speed: 0.15;" look-controls="pointerLockEnabled: true">
530
- <a-camera></a-camera>
531
- </a-entity>
532
-
533
- <a-entity id="players-container"></a-entity>
534
-
535
- <a-plane id="youtube-screen" position="0 3 -10" rotation="0 0 0" width="16" height="9" material="shader: flat; color: #111"></a-plane>
536
-
537
- <a-sky color="#6EBAA7"></a-sky>
538
- <a-plane position="0 0 -4" rotation="-90 0 0" width="50" height="50" color="#7BC8A4" shadow></a-plane>
539
-
540
- <a-light type="ambient" color="#BBB"></a-light>
541
- <a-light type="directional" position="-1 1 1" intensity="0.6"></a-light>
542
- </a-scene>
543
-
544
- <script>
545
  const socket = io();
546
  const token = '{{ token }}';
547
  const username = '{{ username }}';
548
- const is_guest = {{ is_guest|tojson }};
549
  const isAdmin = {{ is_admin|tojson }};
550
- const peers = {};
551
- const iceConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
552
- let localStream;
553
- let player;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  let isHandlingSync = false;
555
 
556
- function copyRoomLink() {
557
- navigator.clipboard.writeText(window.location.href).then(() => alert('Ссылка скопирована!'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
558
  }
559
 
560
- function toggleAdminPanel() {
561
- const panel = document.getElementById('admin-panel');
562
- panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
563
- }
564
-
565
- function onYouTubeIframeAPIReady() {
566
- player = new YT.Player('youtube-player', {
567
- events: {
568
- 'onReady': onPlayerReady,
569
- 'onStateChange': onPlayerStateChange
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  }
571
  });
572
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
573
 
574
- function onPlayerReady(event) {
575
- socket.emit('request_youtube_state', { token, username });
576
  }
577
 
578
- function onPlayerStateChange(event) {
579
- if (isHandlingSync) { isHandlingSync = false; return; }
580
- let action = null;
581
- let currentTime = event.target.getCurrentTime();
582
- if (event.data === YT.PlayerState.PLAYING) action = 'play';
583
- else if (event.data === YT.PlayerState.PAUSED) action = 'pause';
584
- if (action) {
585
- socket.emit('youtube_state_change', { token, action, time: currentTime });
586
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  }
588
-
589
- function setYoutubeUrl() {
590
- if (!isAdmin) return;
591
- const url = document.getElementById('youtube-url-input').value;
592
- socket.emit('set_youtube_url', { token, url });
 
 
 
 
 
 
 
 
 
 
 
593
  }
594
 
595
- function getYouTubeVideoId(url) {
596
- if (!url) return null;
597
- const regExp = /^.*(youtu.be\\/|v\\/|u\\/\\w\\/|embed\\/|watch\\?v=|&v=)([^#&?]*).*/;
598
- const match = url.match(regExp);
599
- return (match && match[2].length === 11) ? match[2] : null;
600
  }
601
 
602
- socket.on('connect', () => {
603
- navigator.mediaDevices.getUserMedia({ video: true, audio: true })
604
- .then(stream => {
605
- localStream = stream;
606
- const localVideo = document.getElementById('local-video');
607
- localVideo.srcObject = stream;
608
- socket.emit('join', { token, username, is_guest });
609
- })
610
- .catch(err => {
611
- console.error("Ошибка доступа к камере:", err);
612
- socket.emit('join', { token, username, is_guest });
613
- });
614
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
 
616
- socket.on('init_room', (data) => {
617
- const playersContainer = document.getElementById('players-container');
618
- for (const sid in data.players) {
619
- if (sid !== socket.id) {
620
- createPlayerAvatar(sid, data.players[sid]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
  }
622
- }
623
- if (data.youtube_url) {
624
- updateYoutubePlayer(data.youtube_url, data.youtube_state);
625
- }
626
- });
627
-
628
- socket.on('player_joined', (data) => {
629
- if (data.sid !== socket.id) {
630
- createPlayerAvatar(data.sid, data.player_data);
631
- const peerConnection = createPeerConnection(data.sid);
632
- peerConnection.createOffer()
633
- .then(offer => peerConnection.setLocalDescription(offer))
634
- .then(() => {
635
- socket.emit('signal', { to: data.sid, from_sid: socket.id, token, signal: peerConnection.localDescription });
636
- });
637
- }
638
- });
639
 
640
- socket.on('player_left', (data) => {
641
- const playerEntity = document.getElementById(`player-${data.sid}`);
642
- if (playerEntity) {
643
- playerEntity.parentNode.removeChild(playerEntity);
644
- }
645
- if (peers[data.sid]) {
646
- peers[data.sid].close();
647
- delete peers[data.sid];
648
- }
649
- });
650
-
651
- socket.on('signal', data => {
652
- let peerConnection = peers[data.from_sid];
653
- if (!peerConnection) {
654
- peerConnection = createPeerConnection(data.from_sid);
655
- }
656
 
657
- if (data.signal.type === 'offer') {
658
- peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal))
659
- .then(() => peerConnection.createAnswer())
660
- .then(answer => peerConnection.setLocalDescription(answer))
661
- .then(() => {
662
- socket.emit('signal', { to: data.from_sid, from_sid: socket.id, token, signal: peerConnection.localDescription });
663
- });
664
- } else if (data.signal.type === 'answer') {
665
- peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal));
666
- } else if (data.signal.type === 'candidate') {
667
- peerConnection.addIceCandidate(new RTCIceCandidate(data.signal.candidate));
668
  }
669
- });
 
 
 
 
 
 
 
670
 
671
- function createPeerConnection(remoteSid) {
672
- const peerConnection = new RTCPeerConnection(iceConfig);
673
- peers[remoteSid] = peerConnection;
674
 
675
- if (localStream) {
676
- localStream.getTracks().forEach(track => peerConnection.addTrack(track, localStream));
677
- }
 
 
678
 
679
- peerConnection.onicecandidate = event => {
680
- if (event.candidate) {
681
- socket.emit('signal', { to: remoteSid, from_sid: socket.id, token, signal: { type: 'candidate', candidate: event.candidate } });
682
- }
683
- };
684
 
685
- peerConnection.ontrack = event => {
686
- const remoteVideo = document.createElement('video');
687
- remoteVideo.id = `video-${remoteSid}`;
688
- remoteVideo.srcObject = event.streams[0];
689
- remoteVideo.autoplay = true;
690
- remoteVideo.playsinline = true;
691
- document.querySelector('a-assets').appendChild(remoteVideo);
692
-
693
- const playerHead = document.querySelector(`#player-${remoteSid} .player-head`);
694
- if (playerHead) {
695
- playerHead.setAttribute('material', `shader: flat; src: #video-${remoteSid}`);
 
 
 
 
 
 
 
 
696
  }
697
- };
698
- return peerConnection;
699
  }
700
 
701
- function createPlayerAvatar(sid, playerData) {
702
- const playersContainer = document.getElementById('players-container');
703
- let playerEntity = document.createElement('a-entity');
704
- playerEntity.id = `player-${sid}`;
705
- playerEntity.setAttribute('position', playerData.position);
706
 
707
- playerEntity.innerHTML = `
708
- <a-box class="player-body" position="0 0.85 0" depth="0.4" height="1.0" width="0.8" color="#555"></a-box>
709
- <a-box class="player-head" position="0 1.6 0" height="0.5" width="0.5" depth="0.5" color="#AAA"></a-box>
710
- <a-text value="${playerData.username}" position="0 2.0 0" align="center" color="white" width="4"></a-text>
711
- `;
712
- playersContainer.appendChild(playerEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  }
714
-
715
- socket.on('update_movement', (data) => {
716
- if (data.sid !== socket.id) {
717
- const playerEntity = document.getElementById(`player-${data.sid}`);
718
- if (playerEntity) {
719
- playerEntity.setAttribute('position', data.position);
720
- const head = playerEntity.querySelector('.player-head');
721
- if (head) {
722
- head.setAttribute('rotation', data.rotation);
723
- }
724
- }
725
  }
726
- });
727
-
728
- document.querySelector('a-scene').addEventListener('loaded', () => {
729
- setInterval(() => {
730
- const player = document.getElementById('player');
731
- const camera = player.querySelector('a-camera');
732
- const position = player.getAttribute('position');
733
- const rotation = camera.getAttribute('rotation');
734
- socket.emit('update_movement', { token, position, rotation });
735
- }, 100);
736
- });
737
-
738
- socket.on('set_youtube_url', (data) => {
739
- updateYoutubePlayer(data.url);
740
- });
741
-
742
- socket.on('youtube_state_change', (data) => {
743
- if (player) {
744
- isHandlingSync = true;
745
- const videoId = getYouTubeVideoId(player.getVideoUrl());
746
- const screen = document.getElementById('youtube-screen');
747
- screen.setAttribute('material', `shader: flat; src: #youtube-video-asset-${videoId}`);
748
-
749
- if (data.action === 'play') {
750
- player.seekTo(data.time, true);
751
- player.playVideo();
752
- } else if (data.action === 'pause') {
753
- player.pauseVideo();
754
- player.seekTo(data.time, true);
755
- }
756
  }
757
- });
758
 
759
- socket.on('youtube_initial_state', (data) => {
760
- updateYoutubePlayer(data.url, data.state);
761
- });
762
 
763
- function updateYoutubePlayer(url, state = null) {
764
- const videoId = getYouTubeVideoId(url);
765
- if (!videoId) return;
 
 
 
 
 
 
766
 
767
- document.getElementById('youtube-player-container').style.display = 'block';
 
 
 
 
 
 
 
768
 
769
- if (!document.getElementById(`youtube-video-asset-${videoId}`)) {
770
- const videoAsset = document.createElement('video');
771
- videoAsset.id = `youtube-video-asset-${videoId}`;
772
- videoAsset.crossOrigin = 'anonymous';
773
- document.querySelector('a-assets').appendChild(videoAsset);
774
- }
 
 
 
775
 
776
- const screen = document.getElementById('youtube-screen');
777
- screen.setAttribute('material', `shader: flat; src: #youtube-video-asset-${videoId}`);
 
 
 
 
778
 
779
- player.cueVideoById(videoId);
780
-
781
- player.getIframe().onload = () => {
782
- const videoEl = document.getElementById(`youtube-video-asset-${videoId}`);
783
- const ytVideoEl = player.getIframe().contentDocument.querySelector('video');
784
- if (ytVideoEl && videoEl.srcObject !== ytVideoEl.captureStream()) {
785
- videoEl.srcObject = ytVideoEl.captureStream();
786
- videoEl.play();
 
 
 
 
 
 
787
  }
788
- }
789
 
790
- if(state) {
 
791
  isHandlingSync = true;
792
- if(state.isPlaying) {
793
- player.seekTo(state.currentTime, true);
794
- player.playVideo();
795
- } else {
796
- player.seekTo(state.currentTime, true);
797
- player.pauseVideo();
 
 
798
  }
799
- }
800
- }
801
-
802
- function startGame(gameId) {
803
- socket.emit('start_game', { token, game_id: gameId });
 
 
 
 
 
 
 
 
 
 
 
804
  }
805
-
806
- socket.on('game_started', (data) => {
807
- const gameId = data.game_id;
808
- const gameInfo = {{ games_data|tojson }}[gameId];
809
- const gameDisplay = document.getElementById('game-display');
810
- gameDisplay.style.display = 'block';
811
- gameDisplay.querySelector('h2').innerText = gameInfo.name;
812
- document.getElementById('game-description').innerText = gameInfo.description;
813
- document.getElementById('game-content').innerHTML = '';
814
-
815
- if (gameId === 'crocodile') initCrocodile(gameId);
816
- else if (gameId === 'alias') initAlias(gameId);
817
- else if (gameId === 'mafia') initMafia(gameId, gameInfo);
818
- else if (gameId === 'durak') initDurak(gameId, gameInfo);
819
- });
820
-
821
- socket.on('update_game_state', (data) => {
822
- const gameId = data.game_id;
823
- if (gameId === 'crocodile') updateCrocodileState(data.state);
824
- else if (gameId === 'alias') updateAliasState(data.state);
825
- else if (gameId === 'mafia') updateMafiaState(data.state);
826
- else if (gameId === 'durak') updateDurakState(data.state);
827
- });
828
-
829
- function initCrocodile(gameId) {
830
- const gameContent = document.getElementById('game-content');
831
- gameContent.innerHTML = `
832
- <div id="crocodile-presenter-view" style="display:none;">
833
- <p>Вы ведущий! Ваше слово: <b id="crocodile-word"></b></p>
834
- </div>
835
- <div id="crocodile-guesser-view" style="display:none;">
836
- <input type="text" id="crocodile-guess-input" class="game-input" placeholder="Ваша догадка">
837
- <button id="crocodile-guess-button" class="game-button">Угадать</button>
838
- </div>
839
- <div id="crocodile-admin-view" style="display:none;">
840
- <input type="text" id="crocodile-word-input" class="game-input" placeholder="Введите слово для игры">
841
- <button id="start-turn-button" class="game-button">Начать ход</button>
842
- </div>
843
- <div id="crocodile-timer"></div>
844
- <div id="crocodile-guesses"></div>
845
- `;
846
- document.getElementById('start-turn-button').onclick = () => {
847
- const word = document.getElementById('crocodile-word-input').value;
848
- if(word) socket.emit('game_action', { token, game_id: gameId, action: 'start_turn', word });
849
- };
850
- document.getElementById('crocodile-guess-button').onclick = () => {
851
- const guess = document.getElementById('crocodile-guess-input').value;
852
- if(guess) socket.emit('game_action', { token, game_id: gameId, action: 'guess', value: guess, user: username });
853
- };
854
  }
855
- function updateCrocodileState(state) {
856
- document.getElementById('crocodile-presenter-view').style.display = state.presenter === username ? 'block' : 'none';
857
- document.getElementById('crocodile-guesser-view').style.display = state.presenter !== username && state.isRunning ? 'block' : 'none';
858
- document.getElementById('crocodile-admin-view').style.display = isAdmin && !state.isRunning ? 'block' : 'none';
859
- if(state.presenter === username) document.getElementById('crocodile-word').innerText = state.word;
860
- document.getElementById('crocodile-timer').innerText = state.isRunning ? `Время: ${state.timer}` : '';
861
- const guessesDiv = document.getElementById('crocodile-guesses');
862
- guessesDiv.innerHTML = '<h4>Догадки:</h4>';
863
- state.guesses.forEach(g => guessesDiv.innerHTML += `<p>${g.user}: ${g.value} (${g.result})</p>`);
864
- if (state.winner) guessesDiv.innerHTML += `<h4>Победил ${state.winner}! Слово: ${state.word}</h4>`;
865
- if (state.isTimeUp) guessesDiv.innerHTML += `<h4>Время вышло! Слово: ${state.word}</h4>`;
866
  }
867
 
868
- function initAlias(gameId) { initCrocodile(gameId); } // Similar logic
869
- function updateAliasState(state) { updateCrocodileState(state); } // Similar logic
870
-
871
- function initMafia(gameId, gameInfo) {
872
- // Simplified logic for brevity, see full logic in previous answer
 
 
 
 
 
873
  }
874
- function updateMafiaState(state) {}
875
 
876
- function initDurak(gameId, gameInfo) {}
877
- function updateDurakState(state) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
  </script>
879
  </body>
880
  </html>
881
- ''', token=token, session=session, is_admin=is_admin, games_data=games_data, username=username, is_guest=is_guest)
882
-
883
- @app.route('/join_as_guest/<token>', methods=['GET'])
884
- def join_as_guest(token):
885
- if token not in rooms:
886
- return "Комната не найдена", 404
887
- guest_id = 'Гость_' + generate_token()[:6]
888
- session['guest_id'] = guest_id
889
- if 'username' in session:
890
- session.pop('username')
891
- return redirect(url_for('room', token=token))
892
-
893
- @app.route('/guest_login/<token>')
894
- def guest_login(token):
895
- if token not in rooms:
896
- return "Комната не найдена", 404
897
- return render_template_string('''
898
- <!DOCTYPE html><html><head><title>Вход для гостей</title></head><body>
899
- <h1>Вход в комнату как Гость</h1>
900
- <a href="{{ url_for('join_as_guest', token=token) }}">Войти как гость</a>
901
- <p>Или <a href="/">войдите в свой аккаунт</a>.</p>
902
- </body></html>
903
- ''', token=token)
904
-
905
- @socketio.on('join')
906
- def handle_join(data):
907
  token = data['token']
908
  username = data['username']
909
- is_guest = data.get('is_guest', False)
910
- sid = request.sid
911
-
912
- if token in rooms:
913
- join_room(token)
914
-
915
- emit('init_room', {
916
- 'players': rooms[token]['players'],
917
- 'youtube_url': rooms[token].get('youtube_url'),
918
- 'youtube_state': rooms[token].get('youtube_state')
919
- }, to=sid)
920
-
921
- x_pos = random.uniform(-10, 10)
922
- z_pos = random.uniform(-5, 5)
923
 
924
- player_data = {
 
 
925
  'username': username,
926
- 'is_guest': is_guest,
927
- 'position': f"{x_pos} 0 {z_pos}"
 
 
928
  }
929
- rooms[token]['players'][sid] = player_data
930
 
931
- emit('player_joined', {'sid': sid, 'player_data': player_data}, room=token)
932
- save_json(ROOMS_DB, rooms)
933
 
934
  @socketio.on('disconnect')
935
  def handle_disconnect():
936
- sid = request.sid
937
- for token, room_data in list(rooms.items()):
938
- if sid in room_data.get('players', {}):
939
- del rooms[token]['players'][sid]
940
- emit('player_left', {'sid': sid}, room=token)
941
-
942
- if not room_data['players']:
943
- del rooms[token]
944
-
945
- save_json(ROOMS_DB, rooms)
946
  break
947
 
948
- @socketio.on('update_movement')
949
- def handle_update_movement(data):
 
 
 
 
 
 
 
 
 
950
  token = data['token']
951
- if token in rooms and request.sid in rooms[token]['players']:
952
- rooms[token]['players'][request.sid]['position'] = ' '.join(map(str, data['position'].values()))
953
- emit('update_movement', {
954
- 'sid': request.sid,
955
- 'position': data['position'],
956
- 'rotation': data['rotation']
957
- }, room=token, include_self=False)
958
 
959
- @socketio.on('signal')
960
- def handle_signal(data):
961
- emit('signal', {
962
- 'from_sid': data['from_sid'],
963
- 'signal': data['signal']
964
- }, room=data['to'])
 
 
 
 
 
965
 
966
  @socketio.on('set_youtube_url')
967
  def handle_set_youtube_url(data):
968
  token = data['token']
969
  url = data['url']
970
- if token in rooms and rooms[token]['admin'] == session.get('username'):
971
- rooms[token]['youtube_url'] = url
972
- rooms[token]['youtube_state'] = {'isPlaying': False, 'currentTime': 0, 'last_sync_time': time.time()}
973
- save_json(ROOMS_DB, rooms)
974
- emit('set_youtube_url', {'url': url}, room=token)
 
 
 
975
 
976
  @socketio.on('youtube_state_change')
977
  def handle_youtube_state_change(data):
978
  token = data['token']
979
- if token in rooms:
980
- rooms[token]['youtube_state']['isPlaying'] = (data['action'] == 'play')
981
- rooms[token]['youtube_state']['currentTime'] = data['time']
982
- rooms[token]['youtube_state']['last_sync_time'] = time.time()
983
- save_json(ROOMS_DB, rooms)
 
 
984
  emit('youtube_state_change', data, room=token, include_self=False)
985
 
986
  @socketio.on('request_youtube_state')
987
  def handle_request_youtube_state(data):
988
  token = data['token']
989
- if token in rooms and rooms[token].get('youtube_url'):
990
- state = rooms[token]['youtube_state']
991
- elapsed = time.time() - state['last_sync_time']
992
- estimated_time = state['currentTime'] + elapsed if state['isPlaying'] else state['currentTime']
993
- emit('youtube_initial_state', {
994
- 'url': rooms[token]['youtube_url'],
995
- 'state': {'isPlaying': state['isPlaying'], 'currentTime': estimated_time}
996
- }, to=request.sid)
997
-
998
- @socketio.on('start_game')
999
- def handle_start_game(data):
1000
- token = data['token']
1001
- game_id = data['game_id']
1002
- username = session.get('username')
1003
- if token in rooms and rooms[token].get('admin') == username:
1004
- rooms[token]['current_game'] = game_id
1005
- if token not in games_data[game_id]['state']:
1006
- games_data[game_id]['state'][token] = {}
1007
-
1008
- player_sids = list(rooms[token]['players'].keys())
1009
- players = [rooms[token]['players'][sid]['username'] for sid in player_sids]
1010
- games_data[game_id]['state'][token]['players'] = players
1011
-
1012
- save_json(ROOMS_DB, rooms)
1013
- save_json(GAMES_DB, games_data)
1014
- emit('game_started', {'game_id': game_id}, room=token)
1015
- emit('update_game_state', {'game_id': game_id, 'state': games_data[game_id]['state'][token]}, room=token)
1016
-
1017
- @socketio.on('game_action')
1018
- def handle_game_action(data):
1019
- token = data['token']
1020
- game_id = data.get('game_id')
1021
- action = data.get('action')
1022
- user = data.get('user')
1023
-
1024
- if not (token in rooms and game_id and rooms[token].get('current_game') == game_id):
1025
- return
1026
-
1027
- state = games_data[game_id]['state'][token]
1028
-
1029
- if game_id in ['crocodile', 'alias']:
1030
- if action == 'start_turn':
1031
- players = state.get('players', [])
1032
- if not players: return
1033
- state['word'] = data.get('word')
1034
- state['presenter'] = random.choice(players)
1035
- state['guesses'] = []
1036
- state['timer'] = 60
1037
- state['isRunning'] = True
1038
- state['winner'] = None
1039
- state['isTimeUp'] = False
1040
- socketio.start_background_task(target=game_timer, token=token, game_id=game_id)
1041
- elif action == 'guess' and state.get('isRunning'):
1042
- value = data.get('value', '').lower()
1043
- word = state.get('word', '').lower()
1044
- result = "Угадано!" if value == word else "Неверно"
1045
- state['guesses'].append({'user': user, 'value': data.get('value'), 'result': result})
1046
- if result == "Угадано!":
1047
- state['isRunning'] = False
1048
- state['winner'] = user
1049
-
1050
- games_data[game_id]['state'][token] = state
1051
- save_json(GAMES_DB, games_data)
1052
- emit('update_game_state', {'game_id': game_id, 'state': state}, room=token)
1053
-
1054
- def game_timer(token, game_id):
1055
- with app.app_context():
1056
- while True:
1057
- socketio.sleep(1)
1058
- state = games_data[game_id]['state'].get(token)
1059
- if not state or not state.get('isRunning'):
1060
- break
1061
-
1062
- state['timer'] -= 1
1063
- if state['timer'] <= 0:
1064
- state['isRunning'] = False
1065
- state['isTimeUp'] = True
1066
-
1067
- games_data[game_id]['state'][token] = state
1068
- save_json(GAMES_DB, games_data)
1069
- socketio.emit('update_game_state', {'game_id': game_id, 'state': state}, room=token)
1070
 
1071
  if __name__ == '__main__':
1072
- if HF_TOKEN_WRITE and HF_TOKEN_READ:
1073
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1074
  backup_thread.start()
1075
  socketio.run(app, host='0.0.0.0', port=7860, debug=True, allow_unsafe_werkzeug=True)
 
13
  from urllib.parse import urlparse, parse_qs
14
 
15
  app = Flask(__name__)
16
+ app.config['SECRET_KEY'] = 'your-very-secret-key-here-for-metaverse'
17
  socketio = SocketIO(app, async_mode='threading')
18
 
19
  REPO_ID = "flpolprojects/Clients"
20
+ HF_TOKEN = os.getenv("HF_TOKEN")
 
21
 
22
+ DB_FILE = os.path.join(app.root_path, 'metaverse_db.json')
 
 
23
 
24
+ def load_db():
25
  try:
26
+ if HF_TOKEN:
27
+ local_path = hf_hub_download(
28
+ repo_id=REPO_ID,
29
+ filename="metaverse_db.json",
30
+ repo_type="dataset",
31
+ token=HF_TOKEN,
32
+ local_dir=".",
33
+ local_dir_use_symlinks=False
34
+ )
35
+ print(f"База данных успешно скачана с Hugging Face Hub: {local_path}")
36
+ except RepositoryNotFoundError:
37
+ print("Репозиторий или файл не найден. Используется/создается локальная версия.")
38
  except Exception as e:
39
+ print(f"Ошибка при скачивании с Hugging Face Hub: {e}. Используется/создается локальная версия.")
 
40
 
 
41
  try:
42
+ with open(DB_FILE, 'r', encoding='utf-8') as f:
43
+ return json.load(f)
44
+ except (FileNotFoundError, json.JSONDecodeError):
45
+ return {'users': {}, 'rooms': {}}
 
 
 
46
 
47
+ def save_db(data):
48
  try:
49
+ with open(DB_FILE, 'w', encoding='utf-8') as f:
50
+ json.dump(data, f, indent=4, ensure_ascii=False)
51
+ except OSError as e:
52
+ print(f"Ошибка сохранения JSON в {DB_FILE}: {e}")
53
+
54
+ if HF_TOKEN:
55
+ try:
56
+ api = HfApi()
57
+ api.upload_file(
58
+ path_or_fileobj=DB_FILE,
59
+ path_in_repo="metaverse_db.json",
60
+ repo_id=REPO_ID,
61
+ repo_type="dataset",
62
+ token=HF_TOKEN,
63
+ commit_message=f"Backup: metaverse_db.json ({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})"
64
+ )
65
+ print(f"Файл {DB_FILE} успешно загружен на Hugging Face Hub.")
66
+ except Exception as e:
67
+ print(f"Ошибка при загрузке файла на Hugging Face Hub: {e}")
68
+
69
+ db = load_db()
70
+ if 'users' not in db: db['users'] = {}
71
+ if 'rooms' not in db: db['rooms'] = {}
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  def periodic_backup():
75
  while True:
76
+ time.sleep(300)
77
+ print("Выполняется периодическое сохранение...")
78
+ save_db(db)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  def generate_token():
81
+ return ''.join(random.choices(string.ascii_letters + string.digits, k=16))
82
 
83
  def hash_password(password):
84
  return hashlib.sha256(password.encode('utf-8')).hexdigest()
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @app.route('/', methods=['GET', 'POST'])
87
  def index():
88
  if 'username' in session:
 
94
  password = request.form.get('password')
95
 
96
  if action == 'register':
97
+ if username in db['users']:
98
  return "Пользователь уже существует", 400
99
+ db['users'][username] = {'password': hash_password(password)}
100
+ save_db(db)
101
  session['username'] = username
102
  return redirect(url_for('dashboard'))
103
 
104
  elif action == 'login':
105
+ user = db['users'].get(username)
106
+ if user and user['password'] == hash_password(password):
107
  session['username'] = username
108
  return redirect(url_for('dashboard'))
109
  return "Неверный логин или пароль", 401
 
114
  <head>
115
  <meta charset="UTF-8">
116
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
117
+ <title>Metaverse Login</title>
118
  <style>
119
  :root {
120
+ --primary-color: #007bff;
121
+ --secondary-color: #0056b3;
122
+ --background-color: #1a1a1d;
123
+ --surface-color: #2c2c34;
124
+ --text-color: #f0f0f0;
 
125
  --font-family: 'Roboto', sans-serif;
126
  --border-radius: 12px;
 
127
  }
128
  body {
129
  font-family: var(--font-family);
130
  background-color: var(--background-color);
131
  color: var(--text-color);
132
  margin: 0;
 
133
  display: flex;
134
  justify-content: center;
135
  align-items: center;
 
139
  background-color: var(--surface-color);
140
  padding: 2rem;
141
  border-radius: var(--border-radius);
142
+ box-shadow: 0 4px 15px rgba(0,0,0,0.4);
143
  width: 90%;
144
  max-width: 400px;
145
  text-align: center;
146
  }
147
  h1 {
148
+ font-size: 2.5rem;
149
  margin-bottom: 1.5rem;
150
  color: var(--primary-color);
151
+ text-shadow: 0 0 10px var(--primary-color);
152
  }
153
  input, button {
154
  display: block;
155
  width: 100%;
156
+ padding: 0.85rem;
157
+ margin-bottom: 1.2rem;
158
+ border: 1px solid #444;
159
+ background-color: #333;
160
+ color: var(--text-color);
161
  border-radius: var(--border-radius);
162
  font-size: 1rem;
163
  box-sizing: border-box;
164
+ transition: all 0.3s ease;
165
  }
166
  input:focus {
167
  outline: none;
168
  border-color: var(--primary-color);
169
+ box-shadow: 0 0 8px var(--primary-color);
170
  }
171
  button {
172
  background-color: var(--primary-color);
 
173
  cursor: pointer;
174
  border: none;
175
  font-weight: 500;
 
176
  }
177
  button:hover {
178
  background-color: var(--secondary-color);
179
+ transform: translateY(-2px);
180
+ box-shadow: 0 2px 8px rgba(0,123,255,0.5);
 
 
 
 
 
 
 
 
 
 
 
 
181
  }
182
  </style>
183
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
184
  </head>
185
  <body>
186
  <div class="container">
187
+ <h1>VOXELVERSE</h1>
188
  <form method="post">
189
  <input type="text" name="username" placeholder="Логин" required>
190
  <input type="password" name="password" placeholder="Пароль" required>
191
  <button type="submit" name="action" value="login">Войти</button>
192
+ <button type="submit" name="action" value="register">Регистрация</button>
193
  </form>
194
  </div>
195
  </body>
196
  </html>
197
  ''')
198
 
 
199
  @app.route('/dashboard', methods=['GET', 'POST'])
200
  def dashboard():
201
  if 'username' not in session:
202
  return redirect(url_for('index'))
203
 
 
 
 
 
 
204
  if request.method == 'POST':
205
  action = request.form.get('action')
206
  if action == 'create':
207
  token = generate_token()
208
+ db['rooms'][token] = {
 
209
  'admin': session['username'],
210
+ 'players': {},
211
+ 'youtube': {
212
+ 'url': '',
213
+ 'state': {'isPlaying': False, 'currentTime': 0, 'last_sync_time': time.time()}
214
+ },
215
+ 'chat': []
216
  }
217
+ save_db(db)
218
+ return redirect(url_for('world', token=token))
 
 
219
  elif action == 'join':
220
  token = request.form.get('token')
221
+ if token in db['rooms']:
222
+ return redirect(url_for('world', token=token))
223
+ return "Мир не найден", 404
 
 
 
224
 
225
  return render_template_string('''
226
  <!DOCTYPE html>
 
231
  <title>Панель управления</title>
232
  <style>
233
  :root {
234
+ --primary-color: #007bff;
235
+ --secondary-color: #0056b3;
236
+ --background-color: #1a1a1d;
237
+ --surface-color: #2c2c34;
238
+ --text-color: #f0f0f0;
239
+ --accent-color: #dc3545;
240
  --font-family: 'Roboto', sans-serif;
241
  --border-radius: 12px;
 
242
  }
243
  body {
244
  font-family: var(--font-family);
245
  background-color: var(--background-color);
246
  color: var(--text-color);
247
  margin: 0;
248
+ padding: 2rem;
249
  display: flex;
250
  flex-direction: column;
251
  align-items: center;
 
255
  background-color: var(--surface-color);
256
  padding: 2rem;
257
  border-radius: var(--border-radius);
258
+ box-shadow: 0 4px 15px rgba(0,0,0,0.4);
259
  width: 90%;
260
+ max-width: 500px;
261
  text-align: center;
 
262
  }
263
  h1 {
264
  font-size: 2rem;
 
268
  input, button {
269
  display: block;
270
  width: 100%;
271
+ padding: 0.85rem;
272
  margin-bottom: 1rem;
273
+ border: 1px solid #444;
274
+ background-color: #333;
275
+ color: var(--text-color);
276
  border-radius: var(--border-radius);
277
  font-size: 1rem;
278
  box-sizing: border-box;
279
+ transition: all 0.3s ease;
280
  }
281
  input:focus {
282
  outline: none;
283
  border-color: var(--primary-color);
284
+ box-shadow: 0 0 8px var(--primary-color);
285
  }
286
  button {
287
  background-color: var(--primary-color);
 
288
  cursor: pointer;
289
  border: none;
290
  font-weight: 500;
 
291
  }
292
  button:hover {
293
  background-color: var(--secondary-color);
294
+ transform: translateY(-2px);
 
 
295
  }
296
  .logout-button {
297
  background-color: var(--accent-color);
 
 
298
  }
299
  .logout-button:hover {
300
+ background-color: #b22a37;
 
 
 
 
 
 
 
301
  }
302
  </style>
303
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
 
306
  <div class="container">
307
  <h1>Добро пожаловать, {{ session['username'] }}</h1>
308
  <form method="post">
309
+ <button type="submit" name="action" value="create">Создать новый мир</button>
310
  </form>
311
+ <hr style="border-color: #444; margin: 1.5rem 0;">
312
  <form method="post">
313
+ <input type="text" name="token" placeholder="Введите ID мира" required>
314
+ <button type="submit" name="action" value="join">Войти в мир</button>
315
  </form>
316
+ <form action="/logout" method="post" style="margin-top: 2rem;">
317
  <button class="logout-button" type="submit">Выйти</button>
318
  </form>
319
  </div>
320
  </body>
321
  </html>
322
+ ''')
323
 
324
  @app.route('/logout', methods=['POST'])
325
  def logout():
326
  session.pop('username', None)
327
  return redirect(url_for('index'))
328
 
329
+ @app.route('/world/<token>')
330
+ def world(token):
331
+ if 'username' not in session:
332
+ return redirect(url_for('index'))
333
+ if token not in db['rooms']:
 
 
334
  return redirect(url_for('dashboard'))
335
 
336
+ username = session['username']
337
+ is_admin = db['rooms'][token]['admin'] == username
338
 
339
  return render_template_string('''
340
  <!DOCTYPE html>
341
  <html lang="ru">
342
  <head>
343
  <meta charset="UTF-8">
344
+ <title>Voxelverse - {{ token }}</title>
345
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
346
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.2/socket.io.min.js"></script>
347
+ <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
348
+ <script type="importmap">
349
+ {
350
+ "imports": {
351
+ "three": "https://unpkg.com/three@0.157.0/build/three.module.js",
352
+ "three/addons/": "https://unpkg.com/three@0.157.0/examples/jsm/"
 
 
 
 
 
 
 
 
 
 
 
353
  }
354
+ }
355
+ </script>
356
+ <script src="https://www.youtube.com/iframe_api"></script>
357
+ <style>
358
+ body { margin: 0; overflow: hidden; background: #000; color: #fff; font-family: monospace; }
359
+ canvas { display: block; }
360
+ #crosshair { position: fixed; top: 50%; left: 50%; width: 4px; height: 4px; background: rgba(255,255,255,0.7); border-radius: 50%; transform: translate(-50%, -50%); }
361
+ #ui-container { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
362
+ #chat-container { position: absolute; bottom: 10px; left: 10px; width: 350px; height: 200px; background: rgba(0,0,0,0.5); border-radius: 8px; display: flex; flex-direction: column; pointer-events: all; }
363
+ #chat-messages { flex-grow: 1; overflow-y: auto; padding: 10px; font-size: 14px; }
364
+ #chat-messages p { margin: 0 0 5px 0; }
365
+ #chat-input { width: 100%; border: none; background: rgba(255,255,255,0.2); color: #fff; padding: 10px; box-sizing: border-box; }
366
+ #joystick-container { position: fixed; bottom: 20px; left: 20px; width: 150px; height: 150px; display: none; pointer-events: all; }
367
+ #joystick-base { position: absolute; width: 100%; height: 100%; background: rgba(255,255,255,0.2); border-radius: 50%; }
368
+ #joystick-thumb { position: absolute; top: 50%; left: 50%; width: 60px; height: 60px; background: rgba(255,255,255,0.4); border-radius: 50%; transform: translate(-50%, -50%); }
369
+ #look-joystick-container { position: fixed; bottom: 20px; right: 20px; width: 150px; height: 150px; display: none; pointer-events: all; }
370
+ #youtube-overlay { position: fixed; 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: 100; pointer-events: all; flex-direction: column; }
371
+ #youtube-player-container { width: 80%; max-width: 960px; aspect-ratio: 16/9; }
372
+ #youtube-controls { margin-top: 20px; display: flex; gap: 10px; }
373
+ .yt-button { padding: 10px 20px; background: #c00; border: none; color: #fff; cursor: pointer; border-radius: 5px; }
374
+ #yt-url-input { padding: 10px; border-radius: 5px; border: 1px solid #555; background: #333; color: #fff; flex-grow: 1; }
375
+ #info-popup { position: fixed; top: 10%; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.7); padding: 20px; border-radius: 10px; border: 1px solid #007bff; display: none; pointer-events: all; text-align: center; }
376
+ @media (hover: none) and (pointer: coarse) {
377
+ #joystick-container, #look-joystick-container { display: block; }
378
+ #crosshair { display: none; }
379
  }
 
380
  </style>
 
 
 
 
 
381
  </head>
382
  <body>
383
+ <div id="crosshair"></div>
384
+ <div id="ui-container">
385
+ <div id="chat-container">
386
+ <div id="chat-messages"></div>
387
+ <input type="text" id="chat-input" placeholder="Нажмите Enter для чата...">
388
  </div>
389
+ <div id="joystick-container">
390
+ <div id="joystick-base"></div>
391
+ <div id="joystick-thumb"></div>
392
+ </div>
393
+ <div id="look-joystick-container">
394
+ <div id="joystick-base"></div>
395
+ <div id="joystick-thumb"></div>
396
+ </div>
397
+ <div id="youtube-overlay">
398
+ <div id="youtube-player-container"></div>
399
+ <div id="youtube-controls">
400
+ {% if is_admin %}
401
+ <input type="text" id="yt-url-input" placeholder="Вставьте URL видео YouTube">
402
+ <button class="yt-button" id="yt-load-btn">Загрузить</button>
403
+ {% endif %}
404
+ <button class="yt-button" id="yt-close-btn">Закрыть</button>
405
  </div>
406
+ </div>
407
+ <div id="info-popup">
408
+ <h2>Информация</h2>
409
+ <p id="info-popup-text"></p>
410
+ <button id="info-popup-close">Закрыть</button>
411
  </div>
412
  </div>
413
+ <script type="module">
414
+ import * as THREE from 'three';
415
+ import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  const socket = io();
418
  const token = '{{ token }}';
419
  const username = '{{ username }}';
 
420
  const isAdmin = {{ is_admin|tojson }};
421
+
422
+ let scene, camera, renderer, controls;
423
+ let player, playerVelocity, playerOnFloor;
424
+ const remotePlayers = {};
425
+ const clock = new THREE.Clock();
426
+ const keys = {};
427
+ let interactiveObjects = [];
428
+
429
+ const MAP_SIZE = 32;
430
+ const TILE_SIZE = 5;
431
+ const worldMap = [
432
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
433
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
434
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
435
+ [1,0,0,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
436
+ [1,0,0,4,1,1,1,1,1,1,1,4,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1],
437
+ [1,0,0,4,1,0,0,0,0,0,1,4,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
438
+ [1,0,0,4,1,0,0,0,0,0,1,4,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
439
+ [1,0,0,4,1,0,0,5,0,0,1,4,0,0,0,1,0,0,0,1,1,1,1,1,1,0,0,1,0,0,0,1],
440
+ [1,0,0,4,1,0,0,0,0,0,1,4,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,1],
441
+ [1,0,0,4,1,0,0,0,0,0,1,4,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,1],
442
+ [1,0,0,4,1,1,1,1,1,1,1,4,0,0,0,1,0,0,0,1,1,1,1,1,1,0,0,1,0,0,0,1],
443
+ [1,0,0,4,4,4,4,4,4,4,4,4,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
444
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1],
445
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
446
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
447
+ [1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1],
448
+ [1,2,2,2,2,2,2,2,2,1,0,0,1,2,2,2,2,2,2,2,2,2,1,2,2,2,1,2,2,2,2,1],
449
+ [1,2,2,2,2,2,2,2,2,1,0,0,1,2,2,2,2,2,2,2,2,2,1,2,2,2,1,2,2,2,2,1],
450
+ [1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1],
451
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
452
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
453
+ [1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1],
454
+ [1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
455
+ [1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
456
+ [1,0,0,3,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
457
+ [1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
458
+ [1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1],
459
+ [1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,1],
460
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
461
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
462
+ [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
463
+ [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
464
+ ];
465
+
466
+ const hotspots = [
467
+ { id: 'yt', type: 'youtube', pos: {x: 10 * TILE_SIZE, y: TILE_SIZE * 2, z: 7 * TILE_SIZE}, size: {x: TILE_SIZE*5, y: TILE_SIZE*3, z: 0.2}, rotation: {x: 0, y: Math.PI / 2, z:0}},
468
+ { id: 'info1', type: 'info', pos: {x: 24 * TILE_SIZE, y: TILE_SIZE * 1.5, z: 3 * TILE_SIZE}, text: 'Добро пожаловать в Voxelverse!'},
469
+ { id: 'tp1', type: 'teleport', pos: {x: 2 * TILE_SIZE, y: TILE_SIZE * 0.5, z: 2 * TILE_SIZE}, target: {x: 29 * TILE_SIZE, y: TILE_SIZE, z: 29 * TILE_SIZE}}
470
+ ];
471
+
472
+ let ytPlayer;
473
+ let isYtPlayerReady = false;
474
  let isHandlingSync = false;
475
 
476
+ function init() {
477
+ scene = new THREE.Scene();
478
+ scene.background = new THREE.Color(0x87ceeb);
479
+ scene.fog = new THREE.Fog(0x87ceeb, 0, TILE_SIZE * MAP_SIZE / 2);
480
+
481
+ camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
482
+
483
+ renderer = new THREE.WebGLRenderer({ antialias: true });
484
+ renderer.setSize(window.innerWidth, window.innerHeight);
485
+ renderer.setPixelRatio(window.devicePixelRatio);
486
+ renderer.shadowMap.enabled = true;
487
+ document.body.appendChild(renderer.domElement);
488
+
489
+ const light = new THREE.HemisphereLight(0xeeeeff, 0x777788, 0.9);
490
+ light.position.set(0.5, 1, 0.75);
491
+ scene.add(light);
492
+
493
+ const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
494
+ dirLight.position.set(50, 200, 100);
495
+ dirLight.castShadow = true;
496
+ dirLight.shadow.camera.top = 180;
497
+ dirLight.shadow.camera.bottom = -100;
498
+ dirLight.shadow.camera.left = -120;
499
+ dirLight.shadow.camera.right = 120;
500
+ scene.add(dirLight);
501
+
502
+ createMap();
503
+ createPlayer();
504
+ setupControls();
505
+ setupSocketListeners();
506
+ setupUI();
507
+
508
+ animate();
509
  }
510
 
511
+ function createMap() {
512
+ const textures = {
513
+ grass: new THREE.MeshLambertMaterial({ color: 0x559020 }),
514
+ stone: new THREE.MeshLambertMaterial({ color: 0x888888 }),
515
+ water: new THREE.MeshLambertMaterial({ color: 0x3388cc, transparent: true, opacity: 0.7 }),
516
+ ytScreen: new THREE.MeshBasicMaterial({ color: 0x101010 }),
517
+ info: new THREE.MeshBasicMaterial({ color: 0xffff00 }),
518
+ teleport: new THREE.MeshBasicMaterial({ color: 0x9400D3 })
519
+ };
520
+
521
+ const boxGeo = new THREE.BoxGeometry(TILE_SIZE, TILE_SIZE, TILE_SIZE);
522
+
523
+ for (let z = 0; z < MAP_SIZE; z++) {
524
+ for (let x = 0; x < MAP_SIZE; x++) {
525
+ const tileType = worldMap[z][x];
526
+ if (tileType === 2) continue;
527
+ let material = textures.stone;
528
+ if (tileType === 0) material = textures.grass;
529
+
530
+ const mesh = new THREE.Mesh(boxGeo, material);
531
+ mesh.position.set(x * TILE_SIZE, -TILE_SIZE / 2, z * TILE_SIZE);
532
+ mesh.receiveShadow = true;
533
+ scene.add(mesh);
534
+ }
535
+ }
536
+
537
+ const waterGeo = new THREE.PlaneGeometry(MAP_SIZE * TILE_SIZE, MAP_SIZE * TILE_SIZE);
538
+ const water = new THREE.Mesh(waterGeo, textures.water);
539
+ water.rotation.x = -Math.PI / 2;
540
+ water.position.y = -TILE_SIZE / 4;
541
+ scene.add(water);
542
+
543
+ hotspots.forEach(hotspot => {
544
+ let object;
545
+ if(hotspot.type === 'youtube') {
546
+ const screenGeo = new THREE.BoxGeometry(hotspot.size.x, hotspot.size.y, hotspot.size.z);
547
+ object = new THREE.Mesh(screenGeo, textures.ytScreen);
548
+ object.rotation.set(hotspot.rotation.x, hotspot.rotation.y, hotspot.rotation.z);
549
+ } else if (hotspot.type === 'info') {
550
+ const infoGeo = new THREE.BoxGeometry(TILE_SIZE/2, TILE_SIZE/2, TILE_SIZE/2);
551
+ object = new THREE.Mesh(infoGeo, textures.info);
552
+ } else if (hotspot.type === 'teleport') {
553
+ const tpGeo = new THREE.CylinderGeometry(TILE_SIZE, TILE_SIZE, 0.5, 32);
554
+ object = new THREE.Mesh(tpGeo, textures.teleport);
555
+ }
556
+
557
+ if (object) {
558
+ object.position.set(hotspot.pos.x, hotspot.pos.y, hotspot.pos.z);
559
+ object.userData = { type: 'hotspot', id: hotspot.id, details: hotspot };
560
+ scene.add(object);
561
+ interactiveObjects.push(object);
562
  }
563
  });
564
  }
565
+
566
+ function createPlayer() {
567
+ const headGeo = new THREE.BoxGeometry(0.8, 0.8, 0.8);
568
+ const headMat = new THREE.MeshLambertMaterial({ color: 0xffdbac });
569
+ const head = new THREE.Mesh(headGeo, headMat);
570
+ head.position.y = 1.3;
571
+
572
+ const bodyGeo = new THREE.BoxGeometry(1, 1.5, 0.5);
573
+ const bodyMat = new THREE.MeshLambertMaterial({ color: 0x335599 });
574
+ const body = new THREE.Mesh(bodyGeo, bodyMat);
575
+ body.position.y = 0.25;
576
+
577
+ player = new THREE.Group();
578
+ player.add(head);
579
+ player.add(body);
580
+
581
+ player.position.set(5 * TILE_SIZE, TILE_SIZE, 5 * TILE_SIZE);
582
+ player.add(camera);
583
+ camera.position.set(0, 1.6, 0);
584
+ scene.add(player);
585
 
586
+ playerVelocity = new THREE.Vector3();
587
+ playerOnFloor = false;
588
  }
589
 
590
+ function addRemotePlayer(id, data) {
591
+ const headGeo = new THREE.BoxGeometry(0.8, 0.8, 0.8);
592
+ const headMat = new THREE.MeshLambertMaterial({ color: data.color || 0xffdbac });
593
+ const head = new THREE.Mesh(headGeo, headMat);
594
+ head.position.y = 1.3;
595
+
596
+ const bodyGeo = new THREE.BoxGeometry(1, 1.5, 0.5);
597
+ const bodyMat = new THREE.MeshLambertMaterial({ color: data.bodyColor || 0x993355 });
598
+ const body = new THREE.Mesh(bodyGeo, bodyMat);
599
+ body.position.y = 0.25;
600
+
601
+ const remotePlayer = new THREE.Group();
602
+ remotePlayer.add(head);
603
+ remotePlayer.add(body);
604
+
605
+ const nameText = createNameLabel(data.username);
606
+ nameText.position.y = 2.5;
607
+ remotePlayer.add(nameText);
608
+
609
+ remotePlayer.position.set(data.pos.x, data.pos.y, data.pos.z);
610
+ remotePlayer.rotation.set(data.rot.x, data.rot.y, data.rot.z);
611
+
612
+ remotePlayers[id] = { object: remotePlayer, targetPos: new THREE.Vector3(), targetRot: new THREE.Euler() };
613
+ remotePlayers[id].targetPos.copy(remotePlayer.position);
614
+ remotePlayers[id].targetRot.copy(remotePlayer.rotation);
615
+
616
+ scene.add(remotePlayer);
617
  }
618
+
619
+ function createNameLabel(name) {
620
+ const canvas = document.createElement('canvas');
621
+ const context = canvas.getContext('2d');
622
+ context.font = 'Bold 40px Arial';
623
+ const textWidth = context.measureText(name).width;
624
+ canvas.width = textWidth;
625
+ canvas.height = 50;
626
+ context.font = 'Bold 40px Arial';
627
+ context.fillStyle = 'rgba(255, 255, 255, 0.95)';
628
+ context.fillText(name, 0, 40);
629
+ const texture = new THREE.CanvasTexture(canvas);
630
+ const spriteMaterial = new THREE.SpriteMaterial({ map: texture });
631
+ const sprite = new THREE.Sprite(spriteMaterial);
632
+ sprite.scale.set(textWidth / 100, 0.5, 1.0);
633
+ return sprite;
634
  }
635
 
636
+ function removeRemotePlayer(id) {
637
+ if (remotePlayers[id]) {
638
+ scene.remove(remotePlayers[id].object);
639
+ delete remotePlayers[id];
640
+ }
641
  }
642
 
643
+ function setupControls() {
644
+ controls = new PointerLockControls(camera, renderer.domElement);
645
+ const blocker = document.body;
646
+ blocker.addEventListener('click', () => controls.lock());
647
+
648
+ document.addEventListener('keydown', (e) => keys[e.code] = true);
649
+ document.addEventListener('keyup', (e) => keys[e.code] = false);
650
+
651
+ const joystick = document.getElementById('joystick-container');
652
+ const joystickThumb = document.getElementById('joystick-thumb');
653
+ let joystickActive = false;
654
+ let joystickStart = { x: 0, y: 0 };
655
+ let joystickMove = { x: 0, y: 0 };
656
+
657
+ joystick.addEventListener('touchstart', (e) => {
658
+ e.preventDefault();
659
+ joystickActive = true;
660
+ joystickStart.x = e.touches[0].clientX;
661
+ joystickStart.y = e.touches[0].clientY;
662
+ });
663
+ joystick.addEventListener('touchmove', (e) => {
664
+ if (!joystickActive) return;
665
+ const dx = e.touches[0].clientX - joystickStart.x;
666
+ const dy = e.touches[0].clientY - joystickStart.y;
667
+ const dist = Math.min(75, Math.hypot(dx, dy));
668
+ const angle = Math.atan2(dy, dx);
669
+ joystickMove.x = Math.cos(angle) * dist / 75;
670
+ joystickMove.y = Math.sin(angle) * dist / 75;
671
+ joystickThumb.style.transform = `translate(${Math.cos(angle)*dist-30}px, ${Math.sin(angle)*dist-30}px)`;
672
+ });
673
+ joystick.addEventListener('touchend', () => {
674
+ joystickActive = false;
675
+ joystickMove.x = 0;
676
+ joystickMove.y = 0;
677
+ joystickThumb.style.transform = `translate(-50%, -50%)`;
678
+ });
679
 
680
+ const lookJoystick = document.getElementById('look-joystick-container');
681
+ const lookJoystickThumb = document.getElementById('look-joystick-container').querySelector('#joystick-thumb');
682
+ let lookActive = false;
683
+ let lookLast = { x: 0, y: 0 };
684
+
685
+ lookJoystick.addEventListener('touchstart', (e) => {
686
+ e.preventDefault();
687
+ lookActive = true;
688
+ lookLast.x = e.touches[0].clientX;
689
+ lookLast.y = e.touches[0].clientY;
690
+ });
691
+ lookJoystick.addEventListener('touchmove', (e) => {
692
+ if (!lookActive) return;
693
+ const dx = e.touches[0].clientX - lookLast.x;
694
+ const dy = e.touches[0].clientY - lookLast.y;
695
+ lookLast.x = e.touches[0].clientX;
696
+ lookLast.y = e.touches[0].clientY;
697
+
698
+ player.rotation.y -= dx * 0.005;
699
+ camera.rotation.x -= dy * 0.005;
700
+ camera.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, camera.rotation.x));
701
+ });
702
+ lookJoystick.addEventListener('touchend', () => { lookActive = false; });
703
+ }
704
+
705
+ function setupUI() {
706
+ const chatInput = document.getElementById('chat-input');
707
+ chatInput.addEventListener('keydown', (e) => {
708
+ if (e.key === 'Enter') {
709
+ e.preventDefault();
710
+ if(chatInput.value.trim() !== '') {
711
+ socket.emit('chat_message', { token, username, message: chatInput.value });
712
+ chatInput.value = '';
713
+ }
714
  }
715
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
 
717
+ document.getElementById('info-popup-close').addEventListener('click', () => {
718
+ document.getElementById('info-popup').style.display = 'none';
719
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
720
 
721
+ if (isAdmin) {
722
+ document.getElementById('yt-load-btn').addEventListener('click', () => {
723
+ const url = document.getElementById('yt-url-input').value;
724
+ socket.emit('set_youtube_url', { token, url });
725
+ });
 
 
 
 
 
 
726
  }
727
+ document.getElementById('yt-close-btn').addEventListener('click', () => {
728
+ document.getElementById('youtube-overlay').style.display = 'none';
729
+ if(ytPlayer && ytPlayer.pauseVideo) ytPlayer.pauseVideo();
730
+ });
731
+
732
+ window.onYouTubeIframeAPIReady = () => {
733
+ isYtPlayerReady = true;
734
+ };
735
 
736
+ window.addEventListener('resize', onWindowResize);
737
+ document.addEventListener('click', checkInteraction);
738
+ }
739
 
740
+ function onWindowResize() {
741
+ camera.aspect = window.innerWidth / window.innerHeight;
742
+ camera.updateProjectionMatrix();
743
+ renderer.setSize(window.innerWidth, window.innerHeight);
744
+ }
745
 
746
+ function checkInteraction(event) {
747
+ if (!controls.isLocked) return;
 
 
 
748
 
749
+ const raycaster = new THREE.Raycaster();
750
+ const mouse = new THREE.Vector2(0, 0); // Center of screen
751
+ raycaster.setFromCamera(mouse, camera);
752
+
753
+ const intersects = raycaster.intersectObjects(interactiveObjects);
754
+ if (intersects.length > 0) {
755
+ const object = intersects[0].object;
756
+ const data = object.userData;
757
+ if(data.type === 'hotspot') {
758
+ if (data.id === 'yt') {
759
+ document.getElementById('youtube-overlay').style.display = 'flex';
760
+ socket.emit('request_youtube_state', { token });
761
+ } else if (data.id === 'info1') {
762
+ const popup = document.getElementById('info-popup');
763
+ document.getElementById('info-popup-text').innerText = data.details.text;
764
+ popup.style.display = 'block';
765
+ } else if (data.id === 'tp1') {
766
+ player.position.set(data.details.target.x, data.details.target.y, data.details.target.z);
767
+ }
768
  }
769
+ }
 
770
  }
771
 
772
+ function updatePlayer(delta) {
773
+ if (!controls.isLocked && !joystickActive) return;
774
+
775
+ const speed = 10.0;
776
+ const direction = new THREE.Vector3();
777
 
778
+ const forward = (keys['KeyW'] || joystickMove.y < -0.1) ? 1 : 0;
779
+ const backward = (keys['KeyS'] || joystickMove.y > 0.1) ? 1 : 0;
780
+ const left = (keys['KeyA'] || joystickMove.x < -0.1) ? 1 : 0;
781
+ const right = (keys['KeyD'] || joystickMove.x > 0.1) ? 1 : 0;
782
+
783
+ direction.z = forward - backward;
784
+ direction.x = left - right;
785
+ direction.normalize();
786
+
787
+ if (controls.isLocked) {
788
+ controls.moveRight(direction.x * speed * delta);
789
+ controls.moveForward(direction.z * speed * delta);
790
+ } else {
791
+ player.translateX(-direction.x * speed * delta);
792
+ player.translateZ(-direction.z * speed * delta);
793
+ }
794
+
795
+ player.position.y += playerVelocity.y * delta;
796
+
797
+ if(player.position.y < TILE_SIZE) {
798
+ playerVelocity.y = 0;
799
+ player.position.y = TILE_SIZE;
800
+ playerOnFloor = true;
801
+ }
802
+ if(keys['Space'] && playerOnFloor) {
803
+ playerVelocity.y = 10;
804
+ playerOnFloor = false;
805
+ }
806
+ playerVelocity.y -= 25 * delta;
807
  }
808
+
809
+ function updateRemotePlayers(delta) {
810
+ for (const id in remotePlayers) {
811
+ const p = remotePlayers[id];
812
+ p.object.position.lerp(p.targetPos, delta * 10);
813
+ p.object.quaternion.slerp(new THREE.Quaternion().setFromEuler(p.targetRot), delta * 10);
 
 
 
 
 
814
  }
815
+ }
816
+
817
+ let lastUpdateTime = 0;
818
+ function animate() {
819
+ requestAnimationFrame(animate);
820
+ const delta = clock.getDelta();
821
+
822
+ updatePlayer(delta);
823
+ updateRemotePlayers(delta);
824
+
825
+ const time = performance.now();
826
+ if (time > lastUpdateTime + 100) { // 10hz update
827
+ socket.emit('player_move', {
828
+ token: token,
829
+ pos: {x: player.position.x, y: player.position.y, z: player.position.z},
830
+ rot: {x: camera.rotation.x, y: player.rotation.y, z: camera.rotation.z}
831
+ });
832
+ lastUpdateTime = time;
 
 
 
 
 
 
 
 
 
 
 
 
833
  }
 
834
 
835
+ renderer.render(scene, camera);
836
+ }
 
837
 
838
+ function setupSocketListeners() {
839
+ socket.on('connect', () => {
840
+ socket.emit('join_world', {
841
+ token: token,
842
+ username: username,
843
+ pos: {x: player.position.x, y: player.position.y, z: player.position.z},
844
+ rot: {x: camera.rotation.x, y: player.rotation.y, z: camera.rotation.z}
845
+ });
846
+ });
847
 
848
+ socket.on('initial_state', (data) => {
849
+ for (const id in data.players) {
850
+ if (id !== socket.id) {
851
+ addRemotePlayer(id, data.players[id]);
852
+ }
853
+ }
854
+ data.chat.forEach(msg => displayChatMessage(msg.username, msg.message));
855
+ });
856
 
857
+ socket.on('player_joined', (data) => {
858
+ if (data.id !== socket.id) {
859
+ addRemotePlayer(data.id, data.playerData);
860
+ }
861
+ });
862
+
863
+ socket.on('player_left', (id) => {
864
+ removeRemotePlayer(id);
865
+ });
866
 
867
+ socket.on('player_moved', (data) => {
868
+ if (remotePlayers[data.id]) {
869
+ remotePlayers[data.id].targetPos.set(data.pos.x, data.pos.y, data.pos.z);
870
+ remotePlayers[data.id].targetRot.set(data.rot.x, data.rot.y, data.rot.z);
871
+ }
872
+ });
873
 
874
+ socket.on('new_chat_message', (data) => {
875
+ displayChatMessage(data.username, data.message);
876
+ });
877
+
878
+ socket.on('set_youtube_url', (data) => {
879
+ if (isYtPlayerReady && data.url) {
880
+ const videoId = getYouTubeVideoId(data.url);
881
+ if (videoId) {
882
+ if (ytPlayer) {
883
+ ytPlayer.loadVideoById(videoId);
884
+ } else {
885
+ createYtPlayer(videoId);
886
+ }
887
+ }
888
  }
889
+ });
890
 
891
+ socket.on('youtube_state_change', (data) => {
892
+ if (!ytPlayer) return;
893
  isHandlingSync = true;
894
+ if (data.action === 'play') {
895
+ if(Math.abs(ytPlayer.getCurrentTime() - data.time) > 1.5) {
896
+ ytPlayer.seekTo(data.time, true);
897
+ }
898
+ ytPlayer.playVideo();
899
+ } else if (data.action === 'pause') {
900
+ ytPlayer.pauseVideo();
901
+ ytPlayer.seekTo(data.time, true);
902
  }
903
+ });
904
+
905
+ socket.on('youtube_initial_state', (data) => {
906
+ if (isYtPlayerReady && data.url) {
907
+ const videoId = getYouTubeVideoId(data.url);
908
+ if(videoId) {
909
+ if(!ytPlayer) createYtPlayer(videoId);
910
+
911
+ isHandlingSync = true;
912
+ ytPlayer.cueVideoById(videoId, data.time || 0);
913
+ if (data.isPlaying) {
914
+ setTimeout(()=> ytPlayer.playVideo(), 500);
915
+ }
916
+ }
917
+ }
918
+ });
919
  }
920
+
921
+ function displayChatMessage(user, message) {
922
+ const messagesDiv = document.getElementById('chat-messages');
923
+ const p = document.createElement('p');
924
+ p.innerHTML = `<strong>${user}:</strong> ${message}`;
925
+ messagesDiv.appendChild(p);
926
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
927
  }
928
+
929
+ function getYouTubeVideoId(url) {
930
+ if (!url) return null;
931
+ let videoId = null;
932
+ const youtubeRegex = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
933
+ const match = url.match(youtubeRegex);
934
+ if (match) {
935
+ videoId = match[1];
936
+ }
937
+ return videoId;
 
938
  }
939
 
940
+ function createYtPlayer(videoId) {
941
+ ytPlayer = new YT.Player('youtube-player-container', {
942
+ height: '100%',
943
+ width: '100%',
944
+ videoId: videoId,
945
+ playerVars: { 'autoplay': 0, 'controls': 1 },
946
+ events: {
947
+ 'onStateChange': onPlayerStateChange
948
+ }
949
+ });
950
  }
 
951
 
952
+ function onPlayerStateChange(event) {
953
+ if (isHandlingSync) {
954
+ isHandlingSync = false;
955
+ return;
956
+ }
957
+ let action = null;
958
+ if (event.data == YT.PlayerState.PLAYING) {
959
+ action = 'play';
960
+ } else if (event.data == YT.PlayerState.PAUSED) {
961
+ action = 'pause';
962
+ }
963
+ if (action) {
964
+ socket.emit('youtube_state_change', {
965
+ token: token,
966
+ action: action,
967
+ time: event.target.getCurrentTime()
968
+ });
969
+ }
970
+ }
971
+
972
+ init();
973
  </script>
974
  </body>
975
  </html>
976
+ ''', token=token, username=username, is_admin=is_admin)
977
+
978
+ @socketio.on('join_world')
979
+ def handle_join_world(data):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
980
  token = data['token']
981
  username = data['username']
982
+ join_room(token)
983
+
984
+ if token in db['rooms']:
985
+ room = db['rooms'][token]
 
 
 
 
 
 
 
 
 
 
986
 
987
+ emit('initial_state', {'players': room['players'], 'chat': room.get('chat', [])}, to=request.sid)
988
+
989
+ room['players'][request.sid] = {
990
  'username': username,
991
+ 'pos': data['pos'],
992
+ 'rot': data['rot'],
993
+ 'color': f'#{random.randint(0, 0xFFFFFF):06x}',
994
+ 'bodyColor': f'#{random.randint(0, 0xFFFFFF):06x}'
995
  }
 
996
 
997
+ emit('player_joined', {'id': request.sid, 'playerData': room['players'][request.sid]}, room=token, include_self=False)
998
+ save_db(db)
999
 
1000
  @socketio.on('disconnect')
1001
  def handle_disconnect():
1002
+ for token, room in db['rooms'].items():
1003
+ if request.sid in room['players']:
1004
+ del room['players'][request.sid]
1005
+ leave_room(token)
1006
+ emit('player_left', request.sid, room=token)
1007
+ save_db(db)
 
 
 
 
1008
  break
1009
 
1010
+ @socketio.on('player_move')
1011
+ def handle_player_move(data):
1012
+ token = data['token']
1013
+ if token in db['rooms'] and request.sid in db['rooms'][token]['players']:
1014
+ player_data = db['rooms'][token]['players'][request.sid]
1015
+ player_data['pos'] = data['pos']
1016
+ player_data['rot'] = data['rot']
1017
+ emit('player_moved', {'id': request.sid, 'pos': data['pos'], 'rot': data['rot']}, room=token, include_self=False)
1018
+
1019
+ @socketio.on('chat_message')
1020
+ def handle_chat_message(data):
1021
  token = data['token']
1022
+ if token in db['rooms']:
1023
+ message_data = {'username': data['username'], 'message': data['message']}
1024
+ if 'chat' not in db['rooms'][token]:
1025
+ db['rooms'][token]['chat'] = []
1026
+ db['rooms'][token]['chat'].append(message_data)
1027
+ if len(db['rooms'][token]['chat']) > 50:
1028
+ db['rooms'][token]['chat'].pop(0)
1029
 
1030
+ emit('new_chat_message', message_data, room=token)
1031
+ save_db(db)
1032
+
1033
+ def get_youtube_id(url):
1034
+ if not url: return None
1035
+ parsed_url = urlparse(url)
1036
+ if parsed_url.hostname in ('www.youtube.com', 'youtube.com'):
1037
+ if parsed_url.path == '/watch': return parse_qs(parsed_url.query).get('v', [None])[0]
1038
+ if parsed_url.path.startswith(('/embed/', '/v/')): return parsed_url.path.split('/')[2]
1039
+ elif parsed_url.hostname == 'youtu.be': return parsed_url.path[1:]
1040
+ return None
1041
 
1042
  @socketio.on('set_youtube_url')
1043
  def handle_set_youtube_url(data):
1044
  token = data['token']
1045
  url = data['url']
1046
+ if token in db['rooms'] and db['rooms'][token]['admin'] == session.get('username'):
1047
+ video_id = get_youtube_id(url)
1048
+ if video_id:
1049
+ room = db['rooms'][token]
1050
+ room['youtube']['url'] = url
1051
+ room['youtube']['state'] = {'isPlaying': False, 'currentTime': 0, 'last_sync_time': time.time()}
1052
+ save_db(db)
1053
+ emit('set_youtube_url', {'url': url}, room=token)
1054
 
1055
  @socketio.on('youtube_state_change')
1056
  def handle_youtube_state_change(data):
1057
  token = data['token']
1058
+ if token in db['rooms']:
1059
+ room = db['rooms'][token]
1060
+ state = room['youtube']['state']
1061
+ state['isPlaying'] = (data['action'] == 'play')
1062
+ state['currentTime'] = data.get('time', 0)
1063
+ state['last_sync_time'] = time.time()
1064
+ save_db(db)
1065
  emit('youtube_state_change', data, room=token, include_self=False)
1066
 
1067
  @socketio.on('request_youtube_state')
1068
  def handle_request_youtube_state(data):
1069
  token = data['token']
1070
+ if token in db['rooms']:
1071
+ room = db['rooms'][token]
1072
+ yt_data = room.get('youtube', {})
1073
+ if yt_data.get('url'):
1074
+ state = yt_data['state']
1075
+ elapsed = time.time() - state['last_sync_time']
1076
+ estimated_time = state['currentTime'] + elapsed if state['isPlaying'] else state['currentTime']
1077
+ emit('youtube_initial_state', {
1078
+ 'url': yt_data['url'],
1079
+ 'time': estimated_time,
1080
+ 'isPlaying': state['isPlaying']
1081
+ }, to=request.sid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1082
 
1083
  if __name__ == '__main__':
1084
+ if HF_TOKEN:
1085
  backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1086
  backup_thread.start()
1087
  socketio.run(app, host='0.0.0.0', port=7860, debug=True, allow_unsafe_werkzeug=True)