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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +913 -718
app.py CHANGED
@@ -13,76 +13,110 @@ 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-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,16 +128,15 @@ def index():
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,22 +147,25 @@ def index():
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,88 +175,112 @@ def index():
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,21 +291,22 @@ def dashboard():
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,10 +316,11 @@ def dashboard():
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,36 +330,46 @@ def dashboard():
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,44 +378,208 @@ def dashboard():
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
  {
@@ -353,735 +589,694 @@ def world(token):
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)
 
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(300)
93
+
94
+ rooms = load_json(ROOMS_DB)
95
+ users = load_json(USERS_DB)
96
+ games_data = {}
97
 
98
  def generate_token():
99
+ return ''.join(random.choices(string.ascii_letters + string.digits, k=15))
100
 
101
  def hash_password(password):
102
  return hashlib.sha256(password.encode('utf-8')).hexdigest()
103
 
104
+ def get_youtube_id(url):
105
+ if not url:
106
+ return None
107
+ parsed_url = urlparse(url)
108
+ if parsed_url.hostname in ('www.youtube.com', 'youtube.com'):
109
+ if parsed_url.path == '/watch':
110
+ query = parse_qs(parsed_url.query)
111
+ return query.get('v', [None])[0]
112
+ elif parsed_url.path.startswith('/embed/'):
113
+ return parsed_url.path.split('/embed/')[1].split('?')[0]
114
+ elif parsed_url.path.startswith('/v/'):
115
+ return parsed_url.path.split('/v/')[1].split('?')[0]
116
+ elif parsed_url.hostname in ('youtu.be', 'www.youtu.be'):
117
+ return parsed_url.path[1:].split('?')[0]
118
+ return None
119
+
120
  @app.route('/', methods=['GET', 'POST'])
121
  def index():
122
  if 'username' in session:
 
128
  password = request.form.get('password')
129
 
130
  if action == 'register':
131
+ if username in users:
132
  return "Пользователь уже существует", 400
133
+ users[username] = {'password': hash_password(password), 'rooms': []}
134
+ save_json(USERS_DB, users)
135
  session['username'] = username
136
  return redirect(url_for('dashboard'))
137
 
138
  elif action == 'login':
139
+ if username in users and users[username]['password'] == hash_password(password):
 
140
  session['username'] = username
141
  return redirect(url_for('dashboard'))
142
  return "Неверный логин или пароль", 401
 
147
  <head>
148
  <meta charset="UTF-8">
149
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
150
+ <title>Room</title>
151
  <style>
152
  :root {
153
+ --primary-color: #6200ee;
154
+ --secondary-color: #3700b3;
155
+ --background-color: #ffffff;
156
+ --surface-color: #f5f5f5;
157
+ --text-color: #333333;
158
+ --error-color: #b00020;
159
  --font-family: 'Roboto', sans-serif;
160
  --border-radius: 12px;
161
+ --box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
162
  }
163
  body {
164
  font-family: var(--font-family);
165
  background-color: var(--background-color);
166
  color: var(--text-color);
167
  margin: 0;
168
+ padding: 0;
169
  display: flex;
170
  justify-content: center;
171
  align-items: center;
 
175
  background-color: var(--surface-color);
176
  padding: 2rem;
177
  border-radius: var(--border-radius);
178
+ box-shadow: var(--box-shadow);
179
  width: 90%;
180
  max-width: 400px;
181
  text-align: center;
182
  }
183
  h1 {
184
+ font-size: 2rem;
185
  margin-bottom: 1.5rem;
186
  color: var(--primary-color);
 
187
  }
188
  input, button {
189
  display: block;
190
  width: 100%;
191
+ padding: 0.75rem;
192
+ margin-bottom: 1rem;
193
+ border: 1px solid #ccc;
 
 
194
  border-radius: var(--border-radius);
195
  font-size: 1rem;
196
  box-sizing: border-box;
197
+ transition: border-color 0.3s ease;
198
  }
199
  input:focus {
200
  outline: none;
201
  border-color: var(--primary-color);
 
202
  }
203
  button {
204
  background-color: var(--primary-color);
205
+ color: white;
206
  cursor: pointer;
207
  border: none;
208
  font-weight: 500;
209
+ transition: background-color 0.3s ease;
210
  }
211
  button:hover {
212
  background-color: var(--secondary-color);
213
+ }
214
+ button:active {
215
+ opacity: 0.8;
216
+ }
217
+ .error-message {
218
+ color: var(--error-color);
219
+ margin-top: 0.5rem;
220
+ }
221
+ @media (prefers-color-scheme: dark) {
222
+ :root {
223
+ --background-color: #121212;
224
+ --surface-color: #1e1e1e;
225
+ --text-color: #ffffff;
226
+ }
227
  }
228
  </style>
229
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
230
  </head>
231
  <body>
232
  <div class="container">
233
+ <h1>Room</h1>
234
  <form method="post">
235
  <input type="text" name="username" placeholder="Логин" required>
236
  <input type="password" name="password" placeholder="Пароль" required>
237
  <button type="submit" name="action" value="login">Войти</button>
238
+ <button type="submit" name="action" value="register">Зарегистрироваться</button>
239
  </form>
240
  </div>
241
  </body>
242
  </html>
243
  ''')
244
 
245
+
246
  @app.route('/dashboard', methods=['GET', 'POST'])
247
  def dashboard():
248
  if 'username' not in session:
249
  return redirect(url_for('index'))
250
 
251
+ user = users.get(session['username'])
252
+ if not user:
253
+ session.pop('username', None)
254
+ return redirect(url_for('index'))
255
+
256
+
257
  if request.method == 'POST':
258
  action = request.form.get('action')
259
  if action == 'create':
260
  token = generate_token()
261
+ rooms[token] = {
262
+ 'users': [session['username']],
263
+ 'guests': [],
264
+ 'max_users': 10,
265
  'admin': session['username'],
266
+ 'youtube_url': None,
267
+ 'youtube_state': {'isPlaying': False, 'currentTime': 0, 'last_sync_time': time.time()},
268
+ 'players': {}
 
 
 
269
  }
270
+ users[session['username']]['rooms'].append(token)
271
+ save_json(ROOMS_DB, rooms)
272
+ save_json(USERS_DB, users)
273
+ return redirect(url_for('room', token=token))
274
  elif action == 'join':
275
  token = request.form.get('token')
276
+ if token in rooms and len(rooms[token]['users']) + len(rooms[token]['guests']) < rooms[token]['max_users']:
277
+ if session['username'] not in rooms[token]['users']:
278
+ rooms[token]['users'].append(session['username'])
279
+ users[session['username']]['rooms'].append(token)
280
+ save_json(ROOMS_DB, rooms)
281
+ save_json(USERS_DB, users)
282
+ return redirect(url_for('room', token=token))
283
+ return "Комната не найдена или переполнена", 404
284
 
285
  return render_template_string('''
286
  <!DOCTYPE html>
 
291
  <title>Панель управления</title>
292
  <style>
293
  :root {
294
+ --primary-color: #6200ee;
295
+ --secondary-color: #3700b3;
296
+ --background-color: #ffffff;
297
+ --surface-color: #f5f5f5;
298
+ --text-color: #333333;
299
+ --accent-color: #03dac6;
300
  --font-family: 'Roboto', sans-serif;
301
  --border-radius: 12px;
302
+ --box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
303
  }
304
  body {
305
  font-family: var(--font-family);
306
  background-color: var(--background-color);
307
  color: var(--text-color);
308
  margin: 0;
309
+ padding: 0;
310
  display: flex;
311
  flex-direction: column;
312
  align-items: center;
 
316
  background-color: var(--surface-color);
317
  padding: 2rem;
318
  border-radius: var(--border-radius);
319
+ box-shadow: var(--box-shadow);
320
  width: 90%;
321
+ max-width: 400px;
322
  text-align: center;
323
+ margin-top: 2rem;
324
  }
325
  h1 {
326
  font-size: 2rem;
 
330
  input, button {
331
  display: block;
332
  width: 100%;
333
+ padding: 0.75rem;
334
  margin-bottom: 1rem;
335
+ border: 1px solid #ccc;
 
 
336
  border-radius: var(--border-radius);
337
  font-size: 1rem;
338
  box-sizing: border-box;
339
+ transition: border-color 0.3s ease;
340
  }
341
  input:focus {
342
  outline: none;
343
  border-color: var(--primary-color);
 
344
  }
345
  button {
346
  background-color: var(--primary-color);
347
+ color: white;
348
  cursor: pointer;
349
  border: none;
350
  font-weight: 500;
351
+ transition: background-color 0.3s ease;
352
  }
353
  button:hover {
354
  background-color: var(--secondary-color);
355
+ }
356
+ button:active {
357
+ opacity: 0.8;
358
  }
359
  .logout-button {
360
  background-color: var(--accent-color);
361
+ margin-top: 1rem;
362
+ transition: background-color 0.3s ease;
363
  }
364
  .logout-button:hover {
365
+ filter: brightness(0.9);
366
+ }
367
+ @media (prefers-color-scheme: dark) {
368
+ :root {
369
+ --background-color: #121212;
370
+ --surface-color: #1e1e1e;
371
+ --text-color: #ffffff;
372
+ }
373
  }
374
  </style>
375
  <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
 
378
  <div class="container">
379
  <h1>Добро пожаловать, {{ session['username'] }}</h1>
380
  <form method="post">
381
+ <button type="submit" name="action" value="create">Создать комнату</button>
382
  </form>
 
383
  <form method="post">
384
+ <input type="text" name="token" placeholder="Введите токен комнаты" required>
385
+ <button type="submit" name="action" value="join">Войти в комнату</button>
386
  </form>
387
+ <form action="/logout" method="post">
388
  <button class="logout-button" type="submit">Выйти</button>
389
  </form>
390
  </div>
391
  </body>
392
  </html>
393
+ ''', session=session)
394
+
395
 
396
  @app.route('/logout', methods=['POST'])
397
  def logout():
398
+ username = session.get('username')
399
+ if username and username in users:
400
+ rooms_to_check = list(users[username].get('rooms', []))
401
+ for room_token in rooms_to_check:
402
+ if room_token in rooms:
403
+ if username in rooms[room_token].get('users', []):
404
+ rooms[room_token]['users'].remove(username)
405
+ if username in rooms[room_token].get('players', {}):
406
+ del rooms[room_token]['players'][username]
407
+
408
+ if not rooms[room_token]['users'] and not rooms[room_token]['guests']:
409
+ del rooms[room_token]
410
+ socketio.close_room(room_token)
411
+ elif rooms[room_token].get('admin') == username:
412
+ if rooms[room_token]['users']:
413
+ rooms[room_token]['admin'] = rooms[room_token]['users'][0]
414
+ elif rooms[room_token]['guests']:
415
+ # This case is tricky, guest cannot be admin. Maybe delete room.
416
+ del rooms[room_token]
417
+ socketio.close_room(room_token)
418
+ else:
419
+ del rooms[room_token]
420
+ socketio.close_room(room_token)
421
+
422
+ socketio.emit('user_left', {'username': username}, room=room_token)
423
+ save_json(ROOMS_DB, rooms)
424
+
425
+ users[username]['rooms'] = []
426
+ save_json(USERS_DB, users)
427
  session.pop('username', None)
428
  return redirect(url_for('index'))
429
 
430
+
431
+
432
+ @app.route('/room/<token>')
433
+ def room(token):
434
+ if 'username' not in session and 'guest_id' not in session:
435
+ is_guest_link = True
436
+ else:
437
+ is_guest_link = False
438
+
439
+ if token not in rooms:
440
  return redirect(url_for('dashboard'))
441
 
442
+ is_admin = False
443
+ username = None
444
+ is_guest = False
445
+
446
+ if 'username' in session and session['username'] in rooms[token].get('users', []):
447
+ username = session['username']
448
+ is_admin = rooms[token]['admin'] == username
449
+ elif 'guest_id' in session:
450
+ username = session['guest_id']
451
+ is_guest = True
452
+ if username not in rooms[token]['guests']:
453
+ if len(rooms[token]['users']) + len(rooms[token]['guests']) < rooms[token]['max_users']:
454
+ rooms[token]['guests'].append(username)
455
+ save_json(ROOMS_DB, rooms)
456
+ else:
457
+ return "Комната переполнена", 403
458
+ elif is_guest_link:
459
+ return redirect(url_for('guest_login', token=token))
460
+ else:
461
+ return redirect(url_for('index'))
462
+
463
  return render_template_string('''
464
  <!DOCTYPE html>
465
  <html lang="ru">
466
  <head>
467
  <meta charset="UTF-8">
468
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
469
+ <title>Метавселенная - Комната {{ token }}</title>
470
+ <style>
471
+ body { margin: 0; overflow: hidden; background-color: #000; }
472
+ #world { position: fixed; top: 0; left: 0; width: 100%; height: 100%; }
473
+ #ui-container {
474
+ position: fixed;
475
+ top: 10px;
476
+ left: 10px;
477
+ color: white;
478
+ font-family: sans-serif;
479
+ z-index: 100;
480
+ display: flex;
481
+ flex-direction: column;
482
+ gap: 10px;
483
+ }
484
+ #ui-container button, #ui-container input {
485
+ padding: 8px 12px;
486
+ background-color: rgba(0,0,0,0.5);
487
+ color: white;
488
+ border: 1px solid white;
489
+ border-radius: 5px;
490
+ cursor: pointer;
491
+ }
492
+ #joystick-container {
493
+ position: fixed;
494
+ bottom: 20px;
495
+ left: 20px;
496
+ width: 150px;
497
+ height: 150px;
498
+ z-index: 100;
499
+ display: none;
500
+ }
501
+ #youtube-container {
502
+ position: fixed;
503
+ top: 50%;
504
+ left: 50%;
505
+ transform: translate(-50%, -50%);
506
+ width: 80vw;
507
+ max-width: 800px;
508
+ height: 45vw;
509
+ max-height: 450px;
510
+ background-color: #000;
511
+ border: 2px solid #fff;
512
+ border-radius: 10px;
513
+ z-index: 200;
514
+ display: none;
515
+ flex-direction: column;
516
+ }
517
+ #youtube-container #youtube-player { width: 100%; height: 100%; }
518
+ #youtube-container .close-btn {
519
+ position: absolute;
520
+ top: -30px;
521
+ right: 0;
522
+ background: white;
523
+ color: black;
524
+ border: none;
525
+ border-radius: 50%;
526
+ width: 25px;
527
+ height: 25px;
528
+ cursor: pointer;
529
+ font-weight: bold;
530
+ }
531
+ .touch-surface {
532
+ position: fixed;
533
+ top: 0;
534
+ left: 0;
535
+ width: 100%;
536
+ height: 100%;
537
+ z-index: 99;
538
+ display: none;
539
+ }
540
+ .info-popup {
541
+ position: fixed;
542
+ bottom: 20px;
543
+ left: 50%;
544
+ transform: translateX(-50%);
545
+ background-color: rgba(0,0,0,0.7);
546
+ color: white;
547
+ padding: 15px;
548
+ border-radius: 10px;
549
+ z-index: 150;
550
+ display: none;
551
+ text-align: center;
552
+ }
553
+ .info-popup.show { display: block; }
554
+ </style>
555
+ </head>
556
+ <body>
557
+ <div id="world"></div>
558
+
559
+ <div id="ui-container">
560
+ <button id="leave-button">Покинуть комнату</button>
561
+ <button id="copy-link-button">Копировать ссылку</button>
562
+ {% if is_admin %}
563
+ <div style="display: flex; gap: 5px;">
564
+ <input type="text" id="youtube-url-input" placeholder="Ссылка YouTube">
565
+ <button id="set-youtube-url">Загрузить</button>
566
+ </div>
567
+ {% endif %}
568
+ </div>
569
+
570
+ <div id="joystick-container"></div>
571
+ <div class="touch-surface"></div>
572
+
573
+ <div id="youtube-container">
574
+ <button class="close-btn" onclick="hideYoutubePlayer()">X</button>
575
+ <div id="youtube-player"></div>
576
+ </div>
577
+
578
+ <div id="info-popup">
579
+ <p>Подойдите к экрану, чтобы посмотреть видео</p>
580
+ </div>
581
+
582
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.js"></script>
583
  <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
584
  <script type="importmap">
585
  {
 
589
  }
590
  }
591
  </script>
592
+ <script src="https://cdn.jsdelivr.net/npm/nipplejs@0.10.1/dist/nipplejs.min.js"></script>
593
  <script src="https://www.youtube.com/iframe_api"></script>
594
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  <script type="module">
596
  import * as THREE from 'three';
 
597
 
598
  const socket = io();
599
  const token = '{{ token }}';
600
  const username = '{{ username }}';
601
+ const is_guest = {{ is_guest|tojson }};
602
  const isAdmin = {{ is_admin|tojson }};
603
 
604
+ let scene, camera, renderer, audioListener;
605
+ let player, playerVelocity = new THREE.Vector3();
606
+ let controls = { forward: false, backward: false, left: false, right: false };
607
+ let peers = {};
608
  const clock = new THREE.Clock();
609
+ const iceConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
 
611
+ let youtubePlayer;
612
+ let youtubeHotspot, youtubeScreen;
613
+ let isYoutubePlayerReady = false;
614
+ let isYoutubeVisible = false;
 
 
 
 
 
615
 
616
  function init() {
617
  scene = new THREE.Scene();
618
  scene.background = new THREE.Color(0x87ceeb);
619
+ scene.fog = new THREE.Fog(0x87ceeb, 0, 100);
620
 
621
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
622
+
623
+ audioListener = new THREE.AudioListener();
624
+ camera.add(audioListener);
625
+
626
  renderer = new THREE.WebGLRenderer({ antialias: true });
627
  renderer.setSize(window.innerWidth, window.innerHeight);
628
  renderer.setPixelRatio(window.devicePixelRatio);
629
  renderer.shadowMap.enabled = true;
630
+ document.getElementById('world').appendChild(renderer.domElement);
631
 
632
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.7);
633
+ scene.add(ambientLight);
 
634
 
635
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
636
+ dirLight.position.set(50, 50, 50);
637
  dirLight.castShadow = true;
638
+ dirLight.shadow.mapSize.width = 2048;
639
+ dirLight.shadow.mapSize.height = 2048;
 
 
640
  scene.add(dirLight);
641
 
642
+ const ground = new THREE.Mesh(
643
+ new THREE.BoxGeometry(200, 1, 200),
644
+ new THREE.MeshLambertMaterial({ color: 0x4d9c4b })
645
+ );
646
+ ground.receiveShadow = true;
647
+ ground.position.y = -0.5;
648
+ scene.add(ground);
649
+
650
+ youtubeHotspot = new THREE.Object3D();
651
+ youtubeHotspot.position.set(0, 0, -20);
652
+ scene.add(youtubeHotspot);
653
+
654
+ const screenBack = new THREE.Mesh(
655
+ new THREE.BoxGeometry(16.2, 9.2, 0.5),
656
+ new THREE.MeshLambertMaterial({ color: 0x333333 })
657
+ );
658
+ screenBack.position.y = 5.5;
659
+ youtubeHotspot.add(screenBack);
660
+
661
+ youtubeScreen = new THREE.Mesh(
662
+ new THREE.PlaneGeometry(16, 9),
663
+ new THREE.MeshBasicMaterial({ color: 0x000000, side: THREE.DoubleSide })
664
+ );
665
+ youtubeScreen.position.y = 5.5;
666
+ youtubeScreen.position.z = 0.26;
667
+ youtubeHotspot.add(youtubeScreen);
668
+
669
  createPlayer();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
 
671
+ initControls();
672
 
673
+ animate();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  }
675
 
676
  function createPlayer() {
677
+ const head = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshLambertMaterial({ color: 0xffdbac }));
678
+ head.position.y = 1.5;
679
+ head.castShadow = true;
680
+
681
+ const body = new THREE.Mesh(new THREE.BoxGeometry(1, 1.5, 0.5), new THREE.MeshLambertMaterial({ color: 0x0000ff }));
 
 
 
682
  body.position.y = 0.25;
683
+ body.castShadow = true;
684
+
685
  player = new THREE.Group();
686
  player.add(head);
687
  player.add(body);
688
+ player.position.set(0, 0.5, 5);
 
 
 
689
  scene.add(player);
690
+ camera.position.set(0, 5, 10);
691
+ camera.lookAt(player.position);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  }
693
+
694
+ function initControls() {
695
+ document.addEventListener('keydown', (e) => {
696
+ switch(e.code) {
697
+ case 'KeyW': controls.forward = true; break;
698
+ case 'KeyS': controls.backward = true; break;
699
+ case 'KeyA': controls.left = true; break;
700
+ case 'KeyD': controls.right = true; break;
701
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  });
703
+ document.addEventListener('keyup', (e) => {
704
+ switch(e.code) {
705
+ case 'KeyW': controls.forward = false; break;
706
+ case 'KeyS': controls.backward = false; break;
707
+ case 'KeyA': controls.left = false; break;
708
+ case 'KeyD': controls.right = false; break;
709
+ }
710
  });
711
 
712
+ let isPointerLocked = false;
713
+ document.addEventListener('pointerlockchange', () => {
714
+ isPointerLocked = document.pointerLockElement === renderer.domElement;
 
 
 
 
 
 
 
715
  });
716
+ renderer.domElement.addEventListener('click', () => {
717
+ if (!isPointerLocked) renderer.domElement.requestPointerLock();
 
 
 
 
 
 
 
 
718
  });
719
+ document.addEventListener('mousemove', (e) => {
720
+ if(isPointerLocked) {
721
+ player.rotation.y -= e.movementX * 0.002;
722
+ camera.rotation.x -= e.movementY * 0.002;
723
+ camera.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, camera.rotation.x));
 
 
 
 
 
 
 
724
  }
725
  });
 
 
 
 
726
 
727
+ if ('ontouchstart' in window) {
728
+ document.getElementById('joystick-container').style.display = 'block';
729
+ const touchSurface = document.querySelector('.touch-surface');
730
+ touchSurface.style.display = 'block';
731
+
732
+ const joystick = nipplejs.create({
733
+ zone: document.getElementById('joystick-container'),
734
+ mode: 'static',
735
+ position: { left: '85px', bottom: '85px' },
736
+ color: 'white'
737
  });
 
 
 
 
 
 
 
 
 
738
 
739
+ joystick.on('move', (evt, data) => {
740
+ const angle = data.angle.radian;
741
+ controls.forward = Math.sin(angle) > 0.1;
742
+ controls.backward = Math.sin(angle) < -0.1;
743
+ controls.left = Math.cos(angle) < -0.1;
744
+ controls.right = Math.cos(angle) > 0.1;
745
+ });
746
+
747
+ joystick.on('end', () => {
748
+ controls.forward = false;
749
+ controls.backward = false;
750
+ controls.left = false;
751
+ controls.right = false;
752
+ });
753
 
754
+ let touchStartX, touchStartY;
755
+ touchSurface.addEventListener('touchstart', (e) => {
756
+ if (e.touches.length === 1) {
757
+ touchStartX = e.touches[0].clientX;
758
+ touchStartY = e.touches[0].clientY;
759
+ }
760
+ }, {passive: false});
761
 
762
+ touchSurface.addEventListener('touchmove', (e) => {
763
+ if (e.touches.length === 1) {
764
+ e.preventDefault();
765
+ const dx = e.touches[0].clientX - touchStartX;
766
+ const dy = e.touches[0].clientY - touchStartY;
767
+
768
+ player.rotation.y -= dx * 0.005;
769
+ camera.rotation.x -= dy * 0.005;
770
+ camera.rotation.x = Math.max(-Math.PI/2, Math.min(Math.PI/2, camera.rotation.x));
771
+
772
+ touchStartX = e.touches[0].clientX;
773
+ touchStartY = e.touches[0].clientY;
 
 
 
 
 
 
 
 
 
774
  }
775
+ }, {passive: false});
776
  }
777
  }
778
+
779
  function updatePlayer(delta) {
 
 
780
  const speed = 10.0;
781
  const direction = new THREE.Vector3();
782
+
783
+ if (controls.forward) direction.z = -1;
784
+ if (controls.backward) direction.z = 1;
785
+ if (controls.left) direction.x = -1;
786
+ if (controls.right) direction.x = 1;
787
+
788
+ if (direction.length() > 0) {
789
+ direction.normalize().applyQuaternion(player.quaternion);
790
+ playerVelocity.add(direction.multiplyScalar(speed * delta));
 
 
 
 
 
 
 
791
  }
792
+
793
+ player.position.add(playerVelocity);
794
+ playerVelocity.multiplyScalar(1 - 8.0 * delta);
795
 
796
+ const cameraOffset = new THREE.Vector3(0, 4, 8);
797
+ cameraOffset.applyQuaternion(player.quaternion);
798
+ camera.position.lerp(player.position.clone().add(cameraOffset), 0.1);
799
 
800
+ const lookAtTarget = player.position.clone().add(new THREE.Vector3(0, 2, 0));
801
+ camera.lookAt(lookAtTarget);
 
 
 
 
 
 
 
 
 
802
 
803
+ checkHotspots();
804
+ }
805
+
806
+ function checkHotspots() {
807
+ const distance = player.position.distanceTo(youtubeHotspot.position);
808
+ const popup = document.getElementById('info-popup');
809
+ if (distance < 10) {
810
+ if (!isYoutubeVisible) {
811
+ popup.classList.add('show');
812
+ }
813
+ if (distance < 5 && !isYoutubeVisible) {
814
+ showYoutubePlayer();
815
+ }
816
+ } else {
817
+ popup.classList.remove('show');
818
+ if (isYoutubeVisible) {
819
+ hideYoutubePlayer();
820
+ }
821
  }
822
  }
823
 
 
824
  function animate() {
825
  requestAnimationFrame(animate);
826
  const delta = clock.getDelta();
827
 
828
+ if (player) {
829
+ updatePlayer(delta);
830
+ socket.emit('player_moved', {
831
+ token,
832
+ position: player.position,
833
+ rotation: player.rotation,
 
 
 
834
  });
835
+ }
836
+
837
+ for (const id in peers) {
838
+ const peer = peers[id];
839
+ if (peer.mesh && peer.targetPosition && peer.targetRotation) {
840
+ peer.mesh.position.lerp(peer.targetPosition, 0.1);
841
+ peer.mesh.quaternion.slerp(new THREE.Quaternion().setFromEuler(peer.targetRotation), 0.1);
842
+ }
843
  }
844
 
845
  renderer.render(scene, camera);
846
  }
847
 
848
+ window.addEventListener('resize', () => {
849
+ camera.aspect = window.innerWidth / window.innerHeight;
850
+ camera.updateProjectionMatrix();
851
+ renderer.setSize(window.innerWidth, window.innerHeight);
852
+ });
853
+
854
+ function addPeer(id) {
855
+ if(peers[id] || id === username) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
856
 
857
+ const head = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshLambertMaterial({ color: 0xffdbac }));
858
+ head.position.y = 1.5;
859
+ head.castShadow = true;
 
 
 
 
 
 
 
860
 
861
+ const body = new THREE.Mesh(new THREE.BoxGeometry(1, 1.5, 0.5), new THREE.MeshLambertMaterial({ color: 0xff0000 }));
862
+ body.position.y = 0.25;
863
+ body.castShadow = true;
864
+
865
+ const peerMesh = new THREE.Group();
866
+ peerMesh.add(head);
867
+ peerMesh.add(body);
868
+ scene.add(peerMesh);
869
+
870
+ const nameTexture = new THREE.CanvasTexture(createTextCanvas(id));
871
+ const nameSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: nameTexture, transparent: true }));
872
+ nameSprite.position.y = 3.0;
873
+ nameSprite.scale.set(3, 1.5, 1);
874
+ peerMesh.add(nameSprite);
875
+
876
+ const videoPlane = new THREE.Mesh(
877
+ new THREE.PlaneGeometry(2, 1.125),
878
+ new THREE.MeshBasicMaterial({ color: 0x111111, side: THREE.DoubleSide })
879
+ );
880
+ videoPlane.position.y = 4.2;
881
+ peerMesh.add(videoPlane);
882
+
883
+ peers[id] = {
884
+ mesh: peerMesh,
885
+ videoPlane: videoPlane,
886
+ targetPosition: new THREE.Vector3(),
887
+ targetRotation: new THREE.Euler(),
888
+ pc: new RTCPeerConnection(iceConfig)
889
+ };
890
+
891
+ const peerConnection = peers[id].pc;
892
+
893
+ navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => {
894
+ stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
895
+ }).catch(e => console.error("getUserMedia error", e));
896
+
897
+ peerConnection.ontrack = (event) => {
898
+ const stream = event.streams[0];
899
+ if (stream.getVideoTracks().length > 0) {
900
+ const videoEl = document.createElement('video');
901
+ videoEl.srcObject = stream;
902
+ videoEl.muted = true;
903
+ videoEl.play();
904
+ const videoTexture = new THREE.VideoTexture(videoEl);
905
+ peers[id].videoPlane.material.map = videoTexture;
906
+ peers[id].videoPlane.material.needsUpdate = true;
907
  }
908
+ if (stream.getAudioTracks().length > 0) {
909
+ const peerAudio = new THREE.PositionalAudio(audioListener);
910
+ peerAudio.setMediaStreamSource(stream);
911
+ peerAudio.setRefDistance(1);
912
+ peerAudio.setRolloffFactor(1);
913
+ peers[id].mesh.add(peerAudio);
914
+ peerAudio.play();
 
 
 
 
 
 
915
  }
916
+ };
917
 
918
+ peerConnection.onicecandidate = event => {
919
+ if (event.candidate) {
920
+ socket.emit('signal', { to: id, from: username, token, signal: { type: 'candidate', candidate: event.candidate }});
 
 
 
 
 
 
 
 
 
921
  }
922
+ };
923
  }
924
+
925
+ function removePeer(id) {
926
+ if (peers[id]) {
927
+ if(peers[id].pc) peers[id].pc.close();
928
+ if(peers[id].mesh) scene.remove(peers[id].mesh);
929
+ delete peers[id];
930
+ }
931
  }
932
 
933
+ function createTextCanvas(text) {
934
+ const canvas = document.createElement('canvas');
935
+ const context = canvas.getContext('2d');
936
+ canvas.width = 256;
937
+ canvas.height = 128;
938
+ context.fillStyle = 'rgba(0, 0, 0, 0.7)';
939
+ context.fillRect(0, 20, canvas.width, 88);
940
+ context.fillStyle = 'white';
941
+ context.font = 'bold 32px sans-serif';
942
+ context.textAlign = 'center';
943
+ context.textBaseline = 'middle';
944
+ context.fillText(text, canvas.width / 2, canvas.height / 2);
945
+ return canvas;
946
  }
947
 
948
+ document.getElementById('leave-button').onclick = () => {
949
+ window.location.href = is_guest ? `/guest_login/${token}` : '/dashboard';
950
+ };
951
+
952
+ document.getElementById('copy-link-button').onclick = () => {
953
+ navigator.clipboard.writeText(window.location.href).then(() => alert('Ссылка скопирована!'));
954
+ };
955
+
956
+ if(isAdmin) {
957
+ document.getElementById('set-youtube-url').onclick = () => {
958
+ const url = document.getElementById('youtube-url-input').value;
959
+ if (url) {
960
+ socket.emit('set_youtube_url', { token, url });
961
+ }
962
+ };
963
+ }
964
+
965
+ window.onYouTubeIframeAPIReady = () => {
966
+ youtubePlayer = new YT.Player('youtube-player', {
967
  height: '100%',
968
  width: '100%',
969
+ playerVars: { 'playsinline': 1 },
 
970
  events: {
971
+ 'onReady': () => { isYoutubePlayerReady = true; socket.emit('request_youtube_state', { token }); },
972
  'onStateChange': onPlayerStateChange
973
  }
974
  });
975
+ };
976
 
977
+ let isHandlingSync = false;
978
  function onPlayerStateChange(event) {
979
+ if (isHandlingSync) { isHandlingSync = false; return; }
 
 
 
980
  let action = null;
981
+ if (event.data == YT.PlayerState.PLAYING) action = 'play';
982
+ else if (event.data == YT.PlayerState.PAUSED) action = 'pause';
 
 
 
983
  if (action) {
984
  socket.emit('youtube_state_change', {
985
+ token, action, time: event.target.getCurrentTime()
 
 
986
  });
987
  }
988
  }
989
+
990
+ function showYoutubePlayer() {
991
+ if (!isYoutubePlayerReady) return;
992
+ document.getElementById('youtube-container').style.display = 'flex';
993
+ isYoutubeVisible = true;
994
+ if (document.pointerLockElement) document.exitPointerLock();
995
+ }
996
+
997
+ window.hideYoutubePlayer = () => {
998
+ document.getElementById('youtube-container').style.display = 'none';
999
+ isYoutubeVisible = false;
1000
+ if (youtubePlayer && typeof youtubePlayer.pauseVideo === 'function') {
1001
+ youtubePlayer.pauseVideo();
1002
+ }
1003
+ };
1004
+
1005
+ socket.on('connect', () => {
1006
+ socket.emit('join', { token, username, is_guest });
1007
+ });
1008
+
1009
+ socket.on('init_room', (data) => {
1010
+ for (const id in data.players) {
1011
+ if (id !== username) {
1012
+ addPeer(id);
1013
+ peers[id].targetPosition = new THREE.Vector3().fromArray(data.players[id].position);
1014
+ peers[id].targetRotation = new THREE.Euler().fromArray(data.players[id].rotation);
1015
+ }
1016
+ }
1017
+ });
1018
+
1019
+ socket.on('user_joined', async (data) => {
1020
+ if (data.username === username) return;
1021
+ addPeer(data.username);
1022
+ const peerConnection = peers[data.username].pc;
1023
+ const offer = await peerConnection.createOffer();
1024
+ await peerConnection.setLocalDescription(offer);
1025
+ socket.emit('signal', { to: data.username, from: username, token, signal: peerConnection.localDescription });
1026
+ });
1027
+
1028
+ socket.on('user_left', (data) => {
1029
+ removePeer(data.username);
1030
+ });
1031
+
1032
+ socket.on('player_moved', (data) => {
1033
+ if(data.id !== username && peers[data.id]) {
1034
+ peers[data.id].targetPosition.set(data.position.x, data.position.y, data.position.z);
1035
+ peers[data.id].targetRotation.set(data.rotation._x, data.rotation._y, data.rotation._z, data.rotation._order);
1036
+ }
1037
+ });
1038
+
1039
+ socket.on('signal', async (data) => {
1040
+ if (data.from === username) return;
1041
+ if (!peers[data.from]) addPeer(data.from);
1042
+
1043
+ const peerConnection = peers[data.from].pc;
1044
+ if (data.signal.type === 'offer') {
1045
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal));
1046
+ const answer = await peerConnection.createAnswer();
1047
+ await peerConnection.setLocalDescription(answer);
1048
+ socket.emit('signal', { to: data.from, from: username, token, signal: peerConnection.localDescription });
1049
+ } else if (data.signal.type === 'answer') {
1050
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(data.signal));
1051
+ } else if (data.signal.type === 'candidate') {
1052
+ if (peerConnection.remoteDescription) {
1053
+ await peerConnection.addIceCandidate(new RTCIceCandidate(data.signal.candidate));
1054
+ }
1055
+ }
1056
+ });
1057
+
1058
+ socket.on('set_youtube_url', (data) => {
1059
+ if (isYoutubePlayerReady && data.videoId) {
1060
+ isHandlingSync = true;
1061
+ youtubePlayer.cueVideoById(data.videoId);
1062
+ const videoMaterial = new THREE.MeshBasicMaterial({ map: new THREE.VideoTexture(youtubePlayer.getIframe()) });
1063
+ youtubeScreen.material = videoMaterial;
1064
+ }
1065
+ });
1066
+
1067
+ socket.on('youtube_state_change', (data) => {
1068
+ if (isYoutubePlayerReady) {
1069
+ isHandlingSync = true;
1070
+ if (Math.abs(youtubePlayer.getCurrentTime() - data.time) > 1.5) {
1071
+ youtubePlayer.seekTo(data.time, true);
1072
+ }
1073
+ if (data.action === 'play') youtubePlayer.playVideo();
1074
+ else if (data.action === 'pause') youtubePlayer.pauseVideo();
1075
+ }
1076
+ });
1077
+
1078
+ socket.on('youtube_initial_state', (data) => {
1079
+ if (isYoutubePlayerReady && data.videoId) {
1080
+ isHandlingSync = true;
1081
+ youtubePlayer.cueVideoById(data.videoId, data.time);
1082
+ const iframe = youtubePlayer.getIframe();
1083
+ const videoTexture = new THREE.VideoTexture(iframe);
1084
+ const videoMaterial = new THREE.MeshBasicMaterial({ map: videoTexture });
1085
+ youtubeScreen.material = videoMaterial;
1086
+
1087
+ setTimeout(() => {
1088
+ isHandlingSync = true;
1089
+ if (data.isPlaying) youtubePlayer.playVideo();
1090
+ else youtubePlayer.pauseVideo();
1091
+ }, 1000);
1092
+ }
1093
+ });
1094
+
1095
+ window.addEventListener('beforeunload', () => {
1096
+ socket.emit('leave', { token, username, is_guest });
1097
+ });
1098
 
1099
  init();
1100
+
1101
  </script>
1102
  </body>
1103
  </html>
1104
+ ''', token=token, session=session, is_admin=is_admin, is_guest=is_guest, username=username)
1105
+
1106
+ @app.route('/join_as_guest/<token>', methods=['GET'])
1107
+ def join_as_guest(token):
1108
+ if token not in rooms:
1109
+ return "Комната не найдена", 404
1110
+
1111
+ guest_id = 'Гость_' + ''.join(random.choices(string.digits, k=4))
1112
+ session['guest_id'] = guest_id
1113
 
1114
+ if 'username' in session:
1115
+ session.pop('username')
1116
+
1117
+ return redirect(url_for('room', token=token))
1118
+
1119
+ @app.route('/guest_login/<token>')
1120
+ def guest_login(token):
1121
+ if token not in rooms:
1122
+ return "Комната не найдена", 404
1123
+
1124
+ return render_template_string('''
1125
+ <!DOCTYPE html>
1126
+ <html lang="ru">
1127
+ <head>
1128
+ <meta charset="UTF-8">
1129
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1130
+ <title>Вход для гостей</title>
1131
+ <style>
1132
+ :root {
1133
+ --primary-color: #4CAF50;
1134
+ --background-color: #f0f0f0;
1135
+ --surface-color: #ffffff;
1136
+ --text-color: #333333;
1137
+ --border-radius: 12px;
1138
+ --box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.15);
1139
+ }
1140
+ 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); }
1141
+ .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); }
1142
+ h1 { color: var(--primary-color); margin-bottom: 20px; }
1143
+ p { margin-bottom: 20px; }
1144
+ 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; }
1145
+ @media (prefers-color-scheme: dark) {
1146
+ :root {
1147
+ --background-color: #121212;
1148
+ --surface-color: #1e1e1e;
1149
+ --text-color: #ffffff;
1150
+ }
1151
+ }
1152
+ </style>
1153
+ </head>
1154
+ <body>
1155
+ <div class="container">
1156
+ <h1>Вход в комнату как Гость</h1>
1157
+ <p>Вы можете войти в комнату <strong>{{ token }}</strong> как гость. Ваше имя будет автоматически сгенерировано.</p>
1158
+ <a href="{{ url_for('join_as_guest', token=token) }}">Войти в комнату как гость</a>
1159
+ </div>
1160
+ </body>
1161
+ </html>
1162
+ ''', token=token)
1163
+
1164
+ @socketio.on('join')
1165
+ def handle_join(data):
1166
  token = data['token']
1167
  username = data['username']
1168
+ is_guest = data.get('is_guest', False)
1169
+
1170
+ if token in rooms:
1171
+ join_room(token)
1172
 
1173
+ initial_position = [random.uniform(-10, 10), 0.5, random.uniform(-10, 10)]
1174
+ initial_rotation = [0, 0, 0, 'XYZ']
1175
+
1176
+ rooms[token]['players'][username] = {
1177
+ 'position': initial_position,
1178
+ 'rotation': initial_rotation
 
 
1179
  }
1180
+
1181
+ if is_guest:
1182
+ if username not in rooms[token]['guests']:
1183
+ rooms[token]['guests'].append(username)
1184
+ else:
1185
+ if username not in rooms[token]['users']:
1186
+ rooms[token]['users'].append(username)
1187
+
1188
+ save_json(ROOMS_DB, rooms)
1189
 
1190
+ emit('init_room', {'players': rooms[token]['players']}, to=request.sid)
1191
+ emit('user_joined', {'username': username, 'state': rooms[token]['players'][username]}, room=token, include_self=False)
1192
+
1193
+ @socketio.on('leave')
1194
+ def handle_leave(data):
 
 
 
 
 
 
 
 
 
 
1195
  token = data['token']
1196
+ username = data['username']
1197
+ is_guest = data.get('is_guest', False)
1198
+
1199
+ if token in rooms:
1200
+ leave_room(token)
1201
+
1202
+ if username in rooms[token].get('players', {}):
1203
+ del rooms[token]['players'][username]
1204
+
1205
+ if is_guest and username in rooms[token].get('guests', []):
1206
+ rooms[token]['guests'].remove(username)
1207
+ elif not is_guest and username in rooms[token].get('users', []):
1208
+ rooms[token]['users'].remove(username)
1209
+
1210
+ if not rooms[token]['users'] and not rooms[token]['guests']:
1211
+ del rooms[token]
1212
+ else:
1213
+ if rooms[token].get('admin') == username and rooms[token]['users']:
1214
+ rooms[token]['admin'] = rooms[token]['users'][0]
1215
+
1216
+ save_json(ROOMS_DB, rooms)
1217
+ emit('user_left', {'username': username}, room=token)
1218
+
1219
+ @socketio.on('player_moved')
1220
+ def handle_player_moved(data):
1221
  token = data['token']
1222
+ username = session.get('username') or session.get('guest_id')
1223
+ if token in rooms and username in rooms[token]['players']:
1224
+ player_state = rooms[token]['players'][username]
1225
+ player_state['position'] = [data['position']['x'], data['position']['y'], data['position']['z']]
1226
+ player_state['rotation'] = [data['rotation']['_x'], data['rotation']['_y'], data['rotation']['_z'], data['rotation']['_order']]
 
 
1227
 
1228
+ emit('player_moved', {
1229
+ 'id': username,
1230
+ 'position': data['position'],
1231
+ 'rotation': data['rotation']
1232
+ }, room=token, include_self=False)
1233
 
1234
+ @socketio.on('signal')
1235
+ def handle_signal(data):
1236
+ emit('signal', data, room=data['token'], to=data['to'])
 
 
 
 
 
1237
 
1238
  @socketio.on('set_youtube_url')
1239
  def handle_set_youtube_url(data):
1240
  token = data['token']
1241
  url = data['url']
1242
+ username = session.get('username')
1243
+
1244
+ if token in rooms and rooms[token].get('admin') == username:
1245
  video_id = get_youtube_id(url)
1246
  if video_id:
1247
+ rooms[token]['youtube_url'] = url
1248
+ rooms[token]['youtube_state'] = {'isPlaying': False, 'currentTime': 0, 'last_sync_time': time.time()}
1249
+ save_json(ROOMS_DB, rooms)
1250
+ emit('set_youtube_url', {'videoId': video_id}, room=token)
 
1251
 
1252
  @socketio.on('youtube_state_change')
1253
  def handle_youtube_state_change(data):
1254
  token = data['token']
1255
+ if token in rooms:
1256
+ state = rooms[token]['youtube_state']
 
1257
  state['isPlaying'] = (data['action'] == 'play')
1258
+ state['currentTime'] = data['time']
1259
  state['last_sync_time'] = time.time()
1260
+ save_json(ROOMS_DB, rooms)
1261
  emit('youtube_state_change', data, room=token, include_self=False)
1262
 
1263
  @socketio.on('request_youtube_state')
1264
  def handle_request_youtube_state(data):
1265
  token = data['token']
1266
+ if token in rooms and rooms[token].get('youtube_url'):
1267
+ state = rooms[token]['youtube_state']
1268
+ video_id = get_youtube_id(rooms[token]['youtube_url'])
1269
+
1270
+ elapsed = time.time() - state['last_sync_time']
1271
+ estimated_time = state['currentTime'] + elapsed if state['isPlaying'] else state['currentTime']
1272
+
1273
+ emit('youtube_initial_state', {
1274
+ 'videoId': video_id,
1275
+ 'time': estimated_time,
1276
+ 'isPlaying': state['isPlaying']
1277
+ }, to=request.sid)
1278
 
1279
  if __name__ == '__main__':
1280
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1281
+ backup_thread.start()
1282
+ socketio.run(app, host='0.0.0.0', port=7860, debug=False, allow_unsafe_werkzeug=True)