Upload 9 files
Browse files- README.md +1 -13
- app.py +401 -0
- requirements.txt +37 -0
- static/css/styles.css +306 -0
- static/js/audio.js +302 -0
- static/js/background.js +175 -0
- static/js/game.js +326 -0
- static/js/ui.js +322 -0
- templates/index.html +136 -0
README.md
CHANGED
@@ -1,13 +1 @@
|
|
1 |
-
|
2 |
-
title: Team 16trial
|
3 |
-
emoji: 🏃
|
4 |
-
colorFrom: green
|
5 |
-
colorTo: pink
|
6 |
-
sdk: gradio
|
7 |
-
sdk_version: 5.13.1
|
8 |
-
app_file: app.py
|
9 |
-
pinned: false
|
10 |
-
short_description: trial
|
11 |
-
---
|
12 |
-
|
13 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
1 |
+
# trial
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
ADDED
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from flask import Flask, render_template, request, jsonify
|
2 |
+
from flask_socketio import SocketIO, emit
|
3 |
+
import os
|
4 |
+
import requests
|
5 |
+
import json
|
6 |
+
import uuid
|
7 |
+
from datetime import datetime
|
8 |
+
from dotenv import load_dotenv
|
9 |
+
import logging
|
10 |
+
from werkzeug.utils import secure_filename
|
11 |
+
import random
|
12 |
+
import asyncio
|
13 |
+
|
14 |
+
# Initialize Flask and configure core settings
|
15 |
+
app = Flask(__name__)
|
16 |
+
app.config['SECRET_KEY'] = os.urandom(24) # Generate a random secret key for security
|
17 |
+
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # Limit file uploads to 16MB
|
18 |
+
|
19 |
+
# Initialize SocketIO with CORS support for development
|
20 |
+
socketio = SocketIO(app, cors_allowed_origins="*")
|
21 |
+
|
22 |
+
# Load environment variables from .env file
|
23 |
+
load_dotenv()
|
24 |
+
MISTRAL_API_KEY = os.getenv('MISTRAL_API_KEY')
|
25 |
+
ELEVENLABS_API_KEY = os.getenv('ELEVENLABS_API_KEY')
|
26 |
+
|
27 |
+
# Configure logging to track application behavior
|
28 |
+
logging.basicConfig(level=logging.INFO)
|
29 |
+
logger = logging.getLogger(__name__)
|
30 |
+
|
31 |
+
class GameState:
|
32 |
+
"""Manages the state of all active game sessions."""
|
33 |
+
|
34 |
+
def __init__(self):
|
35 |
+
"""Initialize the game state manager."""
|
36 |
+
self.games = {} # Dictionary to store all active games
|
37 |
+
self.cleanup_interval = 3600 # Cleanup inactive games every hour
|
38 |
+
|
39 |
+
def create_game(self):
|
40 |
+
"""Create a new game session with a unique identifier."""
|
41 |
+
game_id = str(uuid.uuid4())
|
42 |
+
self.games[game_id] = {
|
43 |
+
'players': [],
|
44 |
+
'current_phase': 'setup',
|
45 |
+
'recordings': {},
|
46 |
+
'impostor': None,
|
47 |
+
'votes': {},
|
48 |
+
'question': None,
|
49 |
+
'impostor_answer': None,
|
50 |
+
'modified_recording': None,
|
51 |
+
'round_number': 1,
|
52 |
+
'start_time': datetime.now().isoformat(),
|
53 |
+
'completed_rounds': [],
|
54 |
+
'score': {'impostor_wins': 0, 'player_wins': 0}
|
55 |
+
}
|
56 |
+
return game_id
|
57 |
+
|
58 |
+
def cleanup_inactive_games(self):
|
59 |
+
"""Remove inactive game sessions older than 2 hours."""
|
60 |
+
current_time = datetime.now()
|
61 |
+
inactive_threshold = 7200 # 2 hours in seconds
|
62 |
+
|
63 |
+
for game_id, game in list(self.games.items()):
|
64 |
+
start_time = datetime.fromisoformat(game['start_time'])
|
65 |
+
if (current_time - start_time).total_seconds() > inactive_threshold:
|
66 |
+
del self.games[game_id]
|
67 |
+
|
68 |
+
# Initialize global game state
|
69 |
+
game_state = GameState()
|
70 |
+
|
71 |
+
async def generate_question():
|
72 |
+
"""Generate an engaging question using Mistral AI."""
|
73 |
+
try:
|
74 |
+
headers = {
|
75 |
+
'Authorization': f'Bearer {MISTRAL_API_KEY}',
|
76 |
+
'Content-Type': 'application/json'
|
77 |
+
}
|
78 |
+
|
79 |
+
# Craft a prompt that encourages interesting, personal questions
|
80 |
+
payload = {
|
81 |
+
'messages': [{
|
82 |
+
'role': 'user',
|
83 |
+
'content': '''Generate an engaging personal question for a social game.
|
84 |
+
The question should:
|
85 |
+
1. Encourage creative and unique responses
|
86 |
+
2. Be open-ended but not too philosophical
|
87 |
+
3. Be answerable in 15-30 seconds
|
88 |
+
4. Be appropriate for all ages
|
89 |
+
5. Spark interesting conversation
|
90 |
+
|
91 |
+
Generate only the question, without any additional text.'''
|
92 |
+
}]
|
93 |
+
}
|
94 |
+
|
95 |
+
response = requests.post(
|
96 |
+
'https://api.mistral.ai/v1/chat/completions',
|
97 |
+
headers=headers,
|
98 |
+
json=payload,
|
99 |
+
timeout=10 # Set timeout to handle slow responses
|
100 |
+
)
|
101 |
+
|
102 |
+
if response.status_code == 200:
|
103 |
+
question = response.json()['choices'][0]['message']['content'].strip()
|
104 |
+
logger.info(f"Generated question: {question}")
|
105 |
+
return question
|
106 |
+
else:
|
107 |
+
logger.error(f"Mistral API error: {response.status_code}")
|
108 |
+
# Fallback questions if API fails
|
109 |
+
fallback_questions = [
|
110 |
+
"What is your favorite childhood memory?",
|
111 |
+
"What's the most interesting place you've ever visited?",
|
112 |
+
"What's a skill you'd love to master and why?",
|
113 |
+
"What's the best piece of advice you've ever received?"
|
114 |
+
]
|
115 |
+
return random.choice(fallback_questions)
|
116 |
+
|
117 |
+
except Exception as e:
|
118 |
+
logger.error(f"Error generating question: {str(e)}")
|
119 |
+
return "What is your favorite memory from your childhood?"
|
120 |
+
|
121 |
+
async def generate_impostor_answer(question):
|
122 |
+
"""Generate a convincing impostor response using Mistral AI."""
|
123 |
+
try:
|
124 |
+
headers = {
|
125 |
+
'Authorization': f'Bearer {MISTRAL_API_KEY}',
|
126 |
+
'Content-Type': 'application/json'
|
127 |
+
}
|
128 |
+
|
129 |
+
# Craft a detailed prompt for generating a natural response
|
130 |
+
prompt = f'''Given the question "{question}", generate a detailed and convincing personal response.
|
131 |
+
The response should:
|
132 |
+
1. Sound natural and conversational
|
133 |
+
2. Be 2-3 sentences long
|
134 |
+
3. Include specific details to sound authentic
|
135 |
+
4. Be suitable for text-to-speech conversion
|
136 |
+
5. Avoid complex words or punctuation that might affect voice synthesis
|
137 |
+
|
138 |
+
Generate only the response, without any additional context.'''
|
139 |
+
|
140 |
+
payload = {
|
141 |
+
'messages': [{
|
142 |
+
'role': 'user',
|
143 |
+
'content': prompt
|
144 |
+
}]
|
145 |
+
}
|
146 |
+
|
147 |
+
response = requests.post(
|
148 |
+
'https://api.mistral.ai/v1/chat/completions',
|
149 |
+
headers=headers,
|
150 |
+
json=payload,
|
151 |
+
timeout=10
|
152 |
+
)
|
153 |
+
|
154 |
+
if response.status_code == 200:
|
155 |
+
answer = response.json()['choices'][0]['message']['content'].strip()
|
156 |
+
logger.info(f"Generated impostor answer: {answer}")
|
157 |
+
return answer
|
158 |
+
else:
|
159 |
+
logger.error(f"Mistral API error generating answer: {response.status_code}")
|
160 |
+
return "I have an interesting story about that, but I'd need more time to explain it properly."
|
161 |
+
|
162 |
+
except Exception as e:
|
163 |
+
logger.error(f"Error generating impostor answer: {str(e)}")
|
164 |
+
return "I have an interesting story about that, but I'd need more time to explain it properly."
|
165 |
+
|
166 |
+
async def clone_voice(audio_file):
|
167 |
+
"""Clone a voice using ElevenLabs API."""
|
168 |
+
try:
|
169 |
+
headers = {
|
170 |
+
'xi-api-key': ELEVENLABS_API_KEY
|
171 |
+
}
|
172 |
+
|
173 |
+
with open(audio_file, 'rb') as f:
|
174 |
+
files = {
|
175 |
+
'files': ('recording.wav', f, 'audio/wav')
|
176 |
+
}
|
177 |
+
|
178 |
+
response = requests.post(
|
179 |
+
'https://api.elevenlabs.io/v1/voices/add',
|
180 |
+
headers=headers,
|
181 |
+
files=files,
|
182 |
+
timeout=30
|
183 |
+
)
|
184 |
+
|
185 |
+
if response.status_code == 200:
|
186 |
+
voice_id = response.json().get('voice_id')
|
187 |
+
logger.info(f"Successfully cloned voice: {voice_id}")
|
188 |
+
return voice_id
|
189 |
+
else:
|
190 |
+
logger.error(f"ElevenLabs voice cloning error: {response.status_code}")
|
191 |
+
return None
|
192 |
+
|
193 |
+
except Exception as e:
|
194 |
+
logger.error(f"Error cloning voice: {str(e)}")
|
195 |
+
return None
|
196 |
+
|
197 |
+
async def generate_cloned_speech(voice_id, text):
|
198 |
+
"""Generate speech using ElevenLabs with a cloned voice."""
|
199 |
+
try:
|
200 |
+
headers = {
|
201 |
+
'xi-api-key': ELEVENLABS_API_KEY,
|
202 |
+
'Content-Type': 'application/json'
|
203 |
+
}
|
204 |
+
|
205 |
+
payload = {
|
206 |
+
'text': text,
|
207 |
+
'voice_settings': {
|
208 |
+
'stability': 0.75,
|
209 |
+
'similarity_boost': 0.75
|
210 |
+
}
|
211 |
+
}
|
212 |
+
|
213 |
+
response = requests.post(
|
214 |
+
f'https://api.elevenlabs.io/v1/text-to-speech/{voice_id}',
|
215 |
+
headers=headers,
|
216 |
+
json=payload,
|
217 |
+
timeout=30
|
218 |
+
)
|
219 |
+
|
220 |
+
if response.status_code == 200:
|
221 |
+
filename = f"temp/impostor_audio_{uuid.uuid4()}.mp3"
|
222 |
+
with open(filename, 'wb') as f:
|
223 |
+
f.write(response.content)
|
224 |
+
logger.info(f"Generated cloned speech: {filename}")
|
225 |
+
return filename
|
226 |
+
else:
|
227 |
+
logger.error(f"ElevenLabs speech generation error: {response.status_code}")
|
228 |
+
return None
|
229 |
+
|
230 |
+
except Exception as e:
|
231 |
+
logger.error(f"Error generating cloned speech: {str(e)}")
|
232 |
+
return None
|
233 |
+
|
234 |
+
@app.route('/')
|
235 |
+
def home():
|
236 |
+
"""Serve the main game page."""
|
237 |
+
return render_template('index.html')
|
238 |
+
|
239 |
+
@app.route('/api/start_game', methods=['POST'])
|
240 |
+
async def start_game():
|
241 |
+
"""Initialize a new game session."""
|
242 |
+
try:
|
243 |
+
data = request.get_json()
|
244 |
+
game_id = data.get('game_id')
|
245 |
+
|
246 |
+
if game_id not in game_state.games:
|
247 |
+
return jsonify({'error': 'Game not found'}), 404
|
248 |
+
|
249 |
+
game = game_state.games[game_id]
|
250 |
+
|
251 |
+
# Generate question for the round
|
252 |
+
question = await generate_question()
|
253 |
+
game['question'] = question
|
254 |
+
game['current_phase'] = 'recording'
|
255 |
+
|
256 |
+
return jsonify({
|
257 |
+
'status': 'success',
|
258 |
+
'question': question
|
259 |
+
})
|
260 |
+
|
261 |
+
except Exception as e:
|
262 |
+
logger.error(f"Error starting game: {str(e)}")
|
263 |
+
return jsonify({'error': 'Internal server error'}), 500
|
264 |
+
|
265 |
+
@app.route('/api/submit_recording', methods=['POST'])
|
266 |
+
async def submit_recording():
|
267 |
+
"""Handle voice recording submissions."""
|
268 |
+
try:
|
269 |
+
game_id = request.form.get('game_id')
|
270 |
+
player_id = request.form.get('player_id')
|
271 |
+
audio_file = request.files.get('audio')
|
272 |
+
|
273 |
+
if not all([game_id, player_id, audio_file]):
|
274 |
+
return jsonify({'error': 'Missing required data'}), 400
|
275 |
+
|
276 |
+
if game_id not in game_state.games:
|
277 |
+
return jsonify({'error': 'Game not found'}), 404
|
278 |
+
|
279 |
+
# Save the recording
|
280 |
+
filename = secure_filename(f"recording_{game_id}_{player_id}.wav")
|
281 |
+
filepath = os.path.join('temp', filename)
|
282 |
+
audio_file.save(filepath)
|
283 |
+
|
284 |
+
game_state.games[game_id]['recordings'][player_id] = filepath
|
285 |
+
|
286 |
+
return jsonify({'status': 'success'})
|
287 |
+
|
288 |
+
except Exception as e:
|
289 |
+
logger.error(f"Error submitting recording: {str(e)}")
|
290 |
+
return jsonify({'error': 'Internal server error'}), 500
|
291 |
+
|
292 |
+
@app.route('/api/process_impostor', methods=['POST'])
|
293 |
+
async def process_impostor():
|
294 |
+
"""Handle the complete impostor voice generation process."""
|
295 |
+
try:
|
296 |
+
data = request.get_json()
|
297 |
+
game_id = data.get('game_id')
|
298 |
+
impostor_id = data.get('impostor_id')
|
299 |
+
|
300 |
+
if game_id not in game_state.games:
|
301 |
+
return jsonify({'error': 'Game not found'}), 404
|
302 |
+
|
303 |
+
game = game_state.games[game_id]
|
304 |
+
|
305 |
+
# Generate impostor's answer
|
306 |
+
impostor_answer = await generate_impostor_answer(game['question'])
|
307 |
+
game['impostor_answer'] = impostor_answer
|
308 |
+
|
309 |
+
# Get impostor's original recording
|
310 |
+
original_recording = game['recordings'].get(impostor_id)
|
311 |
+
if not original_recording:
|
312 |
+
return jsonify({'error': 'Original recording not found'}), 404
|
313 |
+
|
314 |
+
# Clone voice
|
315 |
+
voice_id = await clone_voice(original_recording)
|
316 |
+
if not voice_id:
|
317 |
+
return jsonify({'error': 'Voice cloning failed'}), 500
|
318 |
+
|
319 |
+
# Generate modified speech
|
320 |
+
audio_file = await generate_cloned_speech(voice_id, impostor_answer)
|
321 |
+
if not audio_file:
|
322 |
+
return jsonify({'error': 'Speech generation failed'}), 500
|
323 |
+
|
324 |
+
game['modified_recording'] = audio_file
|
325 |
+
|
326 |
+
return jsonify({
|
327 |
+
'status': 'success',
|
328 |
+
'audio_url': audio_file
|
329 |
+
})
|
330 |
+
|
331 |
+
except Exception as e:
|
332 |
+
logger.error(f"Error processing impostor: {str(e)}")
|
333 |
+
return jsonify({'error': 'Internal server error'}), 500
|
334 |
+
|
335 |
+
@socketio.on('join_game')
|
336 |
+
def handle_join_game(data):
|
337 |
+
"""Handle a player joining the game."""
|
338 |
+
game_id = data.get('game_id')
|
339 |
+
player_name = data.get('player_name')
|
340 |
+
|
341 |
+
if game_id in game_state.games:
|
342 |
+
game = game_state.games[game_id]
|
343 |
+
if len(game['players']) < 5:
|
344 |
+
player_id = len(game['players']) + 1
|
345 |
+
game['players'].append({
|
346 |
+
'id': player_id,
|
347 |
+
'name': player_name
|
348 |
+
})
|
349 |
+
emit('player_joined', {
|
350 |
+
'player_id': player_id,
|
351 |
+
'player_name': player_name
|
352 |
+
}, broadcast=True, room=game_id)
|
353 |
+
|
354 |
+
@socketio.on('submit_vote')
|
355 |
+
def handle_vote(data):
|
356 |
+
"""Process player votes and determine round outcome."""
|
357 |
+
game_id = data.get('game_id')
|
358 |
+
voter_id = data.get('voter_id')
|
359 |
+
vote_for = data.get('vote_for')
|
360 |
+
|
361 |
+
if game_id in game_state.games:
|
362 |
+
game = game_state.games[game_id]
|
363 |
+
game['votes'][voter_id] = vote_for
|
364 |
+
|
365 |
+
# Check if all players have voted
|
366 |
+
if len(game['votes']) == len(game['players']):
|
367 |
+
# Calculate results
|
368 |
+
votes_count = {}
|
369 |
+
for vote in game['votes'].values():
|
370 |
+
votes_count[vote] = votes_count.get(vote, 0) + 1
|
371 |
+
|
372 |
+
most_voted = max(votes_count.items(), key=lambda x: x[1])[0]
|
373 |
+
|
374 |
+
# Update scores
|
375 |
+
if most_voted == game['impostor']:
|
376 |
+
game['score']['player_wins'] += 1
|
377 |
+
else:
|
378 |
+
game['score']['impostor_wins'] += 1
|
379 |
+
|
380 |
+
# Store round results
|
381 |
+
game['completed_rounds'].append({
|
382 |
+
'round_number': game['round_number'],
|
383 |
+
'impostor': game['impostor'],
|
384 |
+
'votes': game['votes'].copy(),
|
385 |
+
'most_voted': most_voted
|
386 |
+
})
|
387 |
+
|
388 |
+
# Emit results
|
389 |
+
emit('round_result', {
|
390 |
+
'impostor': game['impostor'],
|
391 |
+
'most_voted': most_voted,
|
392 |
+
'votes': game['votes'],
|
393 |
+
'score': game['score']
|
394 |
+
}, broadcast=True, room=game_id)
|
395 |
+
|
396 |
+
if __name__ == '__main__':
|
397 |
+
# Create temporary directory for recordings if it doesn't exist
|
398 |
+
os.makedirs('temp', exist_ok=True)
|
399 |
+
|
400 |
+
# Start the server
|
401 |
+
socketio.run(app, host='0.0.0.0', port=7860, debug=True)
|
requirements.txt
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Web Framework and Server
|
2 |
+
Flask==2.3.3
|
3 |
+
Flask-SocketIO==5.3.6
|
4 |
+
python-dotenv==1.0.0
|
5 |
+
gunicorn==21.2.0
|
6 |
+
eventlet==0.33.3
|
7 |
+
Werkzeug==2.3.7
|
8 |
+
|
9 |
+
# Real-time Communication
|
10 |
+
websockets==11.0.3
|
11 |
+
python-engineio==4.5.1
|
12 |
+
python-socketio==5.8.0
|
13 |
+
|
14 |
+
# API and Network Requests
|
15 |
+
requests==2.31.0
|
16 |
+
urllib3==2.0.4
|
17 |
+
certifi==2023.7.22
|
18 |
+
|
19 |
+
# Audio Processing
|
20 |
+
pydub==0.25.1
|
21 |
+
soundfile==0.12.1
|
22 |
+
numpy==1.24.3
|
23 |
+
|
24 |
+
# Error Handling and Logging
|
25 |
+
python-json-logger==2.0.7
|
26 |
+
|
27 |
+
# Security
|
28 |
+
PyJWT==2.8.0
|
29 |
+
|
30 |
+
# CORS support
|
31 |
+
Flask-CORS==4.0.0
|
32 |
+
|
33 |
+
# Time zone support
|
34 |
+
pytz==2023.3
|
35 |
+
|
36 |
+
# File handling
|
37 |
+
python-magic==0.4.27
|
static/css/styles.css
ADDED
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/* Base styling and CSS variables for consistent theming */
|
2 |
+
:root {
|
3 |
+
/* Color palette */
|
4 |
+
--bg-primary: #0a0a0a;
|
5 |
+
--bg-secondary: #1a1a1a;
|
6 |
+
--text-primary: #ffffff;
|
7 |
+
--text-secondary: #cccccc;
|
8 |
+
--accent-red: #ff0000;
|
9 |
+
--accent-green: #00ff00;
|
10 |
+
|
11 |
+
/* Animation timings */
|
12 |
+
--transition-speed: 0.3s;
|
13 |
+
|
14 |
+
/* Spacing units */
|
15 |
+
--spacing-sm: 0.5rem;
|
16 |
+
--spacing-md: 1rem;
|
17 |
+
--spacing-lg: 2rem;
|
18 |
+
}
|
19 |
+
|
20 |
+
/* Global reset and base styles */
|
21 |
+
* {
|
22 |
+
margin: 0;
|
23 |
+
padding: 0;
|
24 |
+
box-sizing: border-box;
|
25 |
+
}
|
26 |
+
|
27 |
+
body {
|
28 |
+
font-family: 'Pixelify Sans', sans-serif;
|
29 |
+
background-color: var(--bg-primary);
|
30 |
+
color: var(--text-primary);
|
31 |
+
line-height: 1.6;
|
32 |
+
overflow-x: hidden;
|
33 |
+
}
|
34 |
+
|
35 |
+
/* WebGL background container */
|
36 |
+
#webgl-background {
|
37 |
+
position: fixed;
|
38 |
+
top: 0;
|
39 |
+
left: 0;
|
40 |
+
width: 100vw;
|
41 |
+
height: 100vh;
|
42 |
+
z-index: 1;
|
43 |
+
}
|
44 |
+
|
45 |
+
/* Main game container */
|
46 |
+
#game-container {
|
47 |
+
position: relative;
|
48 |
+
z-index: 2;
|
49 |
+
min-height: 100vh;
|
50 |
+
padding: var(--spacing-lg);
|
51 |
+
display: flex;
|
52 |
+
flex-direction: column;
|
53 |
+
align-items: center;
|
54 |
+
justify-content: center;
|
55 |
+
}
|
56 |
+
|
57 |
+
/* Game pages styling */
|
58 |
+
.game-page {
|
59 |
+
display: none;
|
60 |
+
width: 100%;
|
61 |
+
max-width: 800px;
|
62 |
+
margin: 0 auto;
|
63 |
+
padding: var(--spacing-lg);
|
64 |
+
}
|
65 |
+
|
66 |
+
.game-page.active {
|
67 |
+
display: block;
|
68 |
+
animation: fadeIn 0.5s ease-in-out;
|
69 |
+
}
|
70 |
+
|
71 |
+
/* Landing page specific styles */
|
72 |
+
#landing-page {
|
73 |
+
text-align: center;
|
74 |
+
}
|
75 |
+
|
76 |
+
.game-title {
|
77 |
+
font-size: 4rem;
|
78 |
+
margin-bottom: var(--spacing-lg);
|
79 |
+
text-transform: uppercase;
|
80 |
+
letter-spacing: 0.2em;
|
81 |
+
}
|
82 |
+
|
83 |
+
.title-emphasis {
|
84 |
+
color: var(--accent-red);
|
85 |
+
}
|
86 |
+
|
87 |
+
/* Button styles */
|
88 |
+
.button {
|
89 |
+
background: transparent;
|
90 |
+
border: 2px solid var(--text-primary);
|
91 |
+
color: var(--text-primary);
|
92 |
+
padding: var(--spacing-md) var(--spacing-lg);
|
93 |
+
font-family: 'Pixelify Sans', sans-serif;
|
94 |
+
font-size: 1.2rem;
|
95 |
+
cursor: pointer;
|
96 |
+
transition: all var(--transition-speed) ease;
|
97 |
+
}
|
98 |
+
|
99 |
+
.button:hover {
|
100 |
+
background: var(--text-primary);
|
101 |
+
color: var(--bg-primary);
|
102 |
+
}
|
103 |
+
|
104 |
+
.button:disabled {
|
105 |
+
opacity: 0.5;
|
106 |
+
cursor: not-allowed;
|
107 |
+
}
|
108 |
+
|
109 |
+
/* Player setup page styles */
|
110 |
+
.player-grid {
|
111 |
+
display: grid;
|
112 |
+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
113 |
+
gap: var(--spacing-md);
|
114 |
+
margin-bottom: var(--spacing-lg);
|
115 |
+
}
|
116 |
+
|
117 |
+
.player-avatar {
|
118 |
+
width: 100px;
|
119 |
+
height: 100px;
|
120 |
+
border-radius: 50%;
|
121 |
+
border: 2px solid var(--text-primary);
|
122 |
+
display: flex;
|
123 |
+
align-items: center;
|
124 |
+
justify-content: center;
|
125 |
+
font-size: 2rem;
|
126 |
+
margin: 0 auto;
|
127 |
+
}
|
128 |
+
|
129 |
+
/* Recording phase styles */
|
130 |
+
.question-display {
|
131 |
+
text-align: center;
|
132 |
+
font-size: 1.5rem;
|
133 |
+
margin-bottom: var(--spacing-lg);
|
134 |
+
padding: var(--spacing-md);
|
135 |
+
background: var(--bg-secondary);
|
136 |
+
border-radius: 8px;
|
137 |
+
}
|
138 |
+
|
139 |
+
.timer {
|
140 |
+
position: absolute;
|
141 |
+
top: var(--spacing-md);
|
142 |
+
left: var(--spacing-md);
|
143 |
+
font-size: 2rem;
|
144 |
+
color: var(--accent-red);
|
145 |
+
}
|
146 |
+
|
147 |
+
.recording-controls {
|
148 |
+
display: flex;
|
149 |
+
flex-direction: column;
|
150 |
+
align-items: center;
|
151 |
+
gap: var(--spacing-md);
|
152 |
+
}
|
153 |
+
|
154 |
+
/* Listening phase styles */
|
155 |
+
.audio-player {
|
156 |
+
width: 100%;
|
157 |
+
margin-bottom: var(--spacing-md);
|
158 |
+
}
|
159 |
+
|
160 |
+
.player-list {
|
161 |
+
display: flex;
|
162 |
+
flex-direction: column;
|
163 |
+
gap: var(--spacing-md);
|
164 |
+
}
|
165 |
+
|
166 |
+
/* Voting phase styles */
|
167 |
+
.vote-options {
|
168 |
+
display: grid;
|
169 |
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
170 |
+
gap: var(--spacing-md);
|
171 |
+
margin-top: var(--spacing-lg);
|
172 |
+
}
|
173 |
+
|
174 |
+
.vote-button {
|
175 |
+
width: 100px;
|
176 |
+
height: 100px;
|
177 |
+
border-radius: 50%;
|
178 |
+
border: 2px solid var(--text-primary);
|
179 |
+
background: transparent;
|
180 |
+
color: var(--text-primary);
|
181 |
+
font-size: 1.5rem;
|
182 |
+
cursor: pointer;
|
183 |
+
transition: all var(--transition-speed) ease;
|
184 |
+
}
|
185 |
+
|
186 |
+
.vote-button:hover {
|
187 |
+
background: var(--text-primary);
|
188 |
+
color: var(--bg-primary);
|
189 |
+
}
|
190 |
+
|
191 |
+
/* Results page styles */
|
192 |
+
.results-container {
|
193 |
+
text-align: center;
|
194 |
+
}
|
195 |
+
|
196 |
+
.result-message {
|
197 |
+
font-size: 2rem;
|
198 |
+
margin-bottom: var(--spacing-lg);
|
199 |
+
}
|
200 |
+
|
201 |
+
.impostor-reveal {
|
202 |
+
color: var(--accent-red);
|
203 |
+
font-size: 1.5rem;
|
204 |
+
margin-bottom: var(--spacing-lg);
|
205 |
+
}
|
206 |
+
|
207 |
+
/* Animation keyframes */
|
208 |
+
@keyframes fadeIn {
|
209 |
+
from {
|
210 |
+
opacity: 0;
|
211 |
+
transform: translateY(20px);
|
212 |
+
}
|
213 |
+
to {
|
214 |
+
opacity: 1;
|
215 |
+
transform: translateY(0);
|
216 |
+
}
|
217 |
+
}
|
218 |
+
|
219 |
+
/* Settings icon */
|
220 |
+
.settings-icon {
|
221 |
+
position: absolute;
|
222 |
+
top: var(--spacing-md);
|
223 |
+
right: var(--spacing-md);
|
224 |
+
font-size: 1.5rem;
|
225 |
+
cursor: pointer;
|
226 |
+
transition: transform var(--transition-speed) ease;
|
227 |
+
}
|
228 |
+
|
229 |
+
.settings-icon:hover {
|
230 |
+
transform: rotate(90deg);
|
231 |
+
}
|
232 |
+
|
233 |
+
/* Responsive design */
|
234 |
+
@media (max-width: 768px) {
|
235 |
+
.game-title {
|
236 |
+
font-size: 3rem;
|
237 |
+
}
|
238 |
+
|
239 |
+
.player-grid {
|
240 |
+
grid-template-columns: repeat(2, 1fr);
|
241 |
+
}
|
242 |
+
|
243 |
+
.player-avatar {
|
244 |
+
width: 80px;
|
245 |
+
height: 80px;
|
246 |
+
font-size: 1.5rem;
|
247 |
+
}
|
248 |
+
|
249 |
+
.question-display {
|
250 |
+
font-size: 1.2rem;
|
251 |
+
}
|
252 |
+
}
|
253 |
+
|
254 |
+
@media (max-width: 480px) {
|
255 |
+
.game-title {
|
256 |
+
font-size: 2rem;
|
257 |
+
}
|
258 |
+
|
259 |
+
.player-grid {
|
260 |
+
grid-template-columns: 1fr;
|
261 |
+
}
|
262 |
+
|
263 |
+
.button {
|
264 |
+
width: 100%;
|
265 |
+
margin-bottom: var(--spacing-sm);
|
266 |
+
}
|
267 |
+
}
|
268 |
+
|
269 |
+
/* Loading and transition states */
|
270 |
+
.loading {
|
271 |
+
position: fixed;
|
272 |
+
top: 0;
|
273 |
+
left: 0;
|
274 |
+
width: 100vw;
|
275 |
+
height: 100vh;
|
276 |
+
background: rgba(0, 0, 0, 0.8);
|
277 |
+
display: flex;
|
278 |
+
align-items: center;
|
279 |
+
justify-content: center;
|
280 |
+
z-index: 1000;
|
281 |
+
}
|
282 |
+
|
283 |
+
.loading-spinner {
|
284 |
+
width: 50px;
|
285 |
+
height: 50px;
|
286 |
+
border: 4px solid var(--text-secondary);
|
287 |
+
border-top-color: var(--accent-red);
|
288 |
+
border-radius: 50%;
|
289 |
+
animation: spin 1s linear infinite;
|
290 |
+
}
|
291 |
+
|
292 |
+
@keyframes spin {
|
293 |
+
to {
|
294 |
+
transform: rotate(360deg);
|
295 |
+
}
|
296 |
+
}
|
297 |
+
|
298 |
+
/* Error message styling */
|
299 |
+
.error-message {
|
300 |
+
background: rgba(255, 0, 0, 0.2);
|
301 |
+
border: 1px solid var(--accent-red);
|
302 |
+
padding: var(--spacing-md);
|
303 |
+
margin: var(--spacing-md) 0;
|
304 |
+
border-radius: 4px;
|
305 |
+
text-align: center;
|
306 |
+
}
|
static/js/audio.js
ADDED
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
class AudioManager {
|
2 |
+
constructor() {
|
3 |
+
// Core audio configuration
|
4 |
+
this.config = {
|
5 |
+
sampleRate: 44100,
|
6 |
+
channels: 1,
|
7 |
+
bitDepth: 16,
|
8 |
+
maxRecordingTime: 30000, // 30 seconds in milliseconds
|
9 |
+
minRecordingTime: 15000 // 15 seconds in milliseconds
|
10 |
+
};
|
11 |
+
|
12 |
+
// Recording state management
|
13 |
+
this.state = {
|
14 |
+
isRecording: false,
|
15 |
+
startTime: null,
|
16 |
+
recorder: null,
|
17 |
+
stream: null,
|
18 |
+
audioChunks: [],
|
19 |
+
audioContext: null,
|
20 |
+
analyser: null
|
21 |
+
};
|
22 |
+
|
23 |
+
// Audio visualization settings
|
24 |
+
this.visualizer = {
|
25 |
+
canvasContext: null,
|
26 |
+
dataArray: null,
|
27 |
+
bufferLength: null,
|
28 |
+
width: 0,
|
29 |
+
height: 0
|
30 |
+
};
|
31 |
+
|
32 |
+
// Initialize audio context with error handling
|
33 |
+
try {
|
34 |
+
this.state.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
35 |
+
} catch (error) {
|
36 |
+
console.error('AudioContext not supported in this browser');
|
37 |
+
}
|
38 |
+
|
39 |
+
// Bind methods to maintain context
|
40 |
+
this.startRecording = this.startRecording.bind(this);
|
41 |
+
this.stopRecording = this.stopRecording.bind(this);
|
42 |
+
this.processAudio = this.processAudio.bind(this);
|
43 |
+
}
|
44 |
+
|
45 |
+
/**
|
46 |
+
* Initialize audio visualization on a canvas element
|
47 |
+
* @param {HTMLCanvasElement} canvas - The canvas element for visualization
|
48 |
+
*/
|
49 |
+
initializeVisualizer(canvas) {
|
50 |
+
if (!canvas) return;
|
51 |
+
|
52 |
+
this.visualizer.canvasContext = canvas.getContext('2d');
|
53 |
+
this.visualizer.width = canvas.width;
|
54 |
+
this.visualizer.height = canvas.height;
|
55 |
+
|
56 |
+
// Set up audio analyser for visualization
|
57 |
+
this.state.analyser = this.state.audioContext.createAnalyser();
|
58 |
+
this.state.analyser.fftSize = 2048;
|
59 |
+
this.visualizer.bufferLength = this.state.analyser.frequencyBinCount;
|
60 |
+
this.visualizer.dataArray = new Uint8Array(this.visualizer.bufferLength);
|
61 |
+
}
|
62 |
+
|
63 |
+
/**
|
64 |
+
* Start recording audio with visualization
|
65 |
+
* @returns {Promise<void>}
|
66 |
+
*/
|
67 |
+
async startRecording() {
|
68 |
+
try {
|
69 |
+
// Request microphone access
|
70 |
+
this.state.stream = await navigator.mediaDevices.getUserMedia({
|
71 |
+
audio: {
|
72 |
+
channelCount: this.config.channels,
|
73 |
+
sampleRate: this.config.sampleRate
|
74 |
+
}
|
75 |
+
});
|
76 |
+
|
77 |
+
// Create and configure MediaRecorder
|
78 |
+
this.state.recorder = new MediaRecorder(this.state.stream, {
|
79 |
+
mimeType: 'audio/webm;codecs=opus'
|
80 |
+
});
|
81 |
+
|
82 |
+
// Set up recording event handlers
|
83 |
+
this.state.recorder.ondataavailable = (event) => {
|
84 |
+
if (event.data.size > 0) {
|
85 |
+
this.state.audioChunks.push(event.data);
|
86 |
+
}
|
87 |
+
};
|
88 |
+
|
89 |
+
// Connect audio nodes for visualization
|
90 |
+
const source = this.state.audioContext.createMediaStreamSource(this.state.stream);
|
91 |
+
source.connect(this.state.analyser);
|
92 |
+
|
93 |
+
// Start recording
|
94 |
+
this.state.recorder.start(100); // Collect data every 100ms
|
95 |
+
this.state.isRecording = true;
|
96 |
+
this.state.startTime = Date.now();
|
97 |
+
|
98 |
+
// Start visualization if canvas is set up
|
99 |
+
if (this.visualizer.canvasContext) {
|
100 |
+
this.drawVisualization();
|
101 |
+
}
|
102 |
+
|
103 |
+
// Set up automatic recording stop
|
104 |
+
setTimeout(() => {
|
105 |
+
if (this.state.isRecording) {
|
106 |
+
this.stopRecording();
|
107 |
+
}
|
108 |
+
}, this.config.maxRecordingTime);
|
109 |
+
|
110 |
+
} catch (error) {
|
111 |
+
console.error('Error starting recording:', error);
|
112 |
+
throw new Error('Failed to start recording');
|
113 |
+
}
|
114 |
+
}
|
115 |
+
|
116 |
+
/**
|
117 |
+
* Stop recording and process the audio
|
118 |
+
* @returns {Promise<Blob>} The processed audio blob
|
119 |
+
*/
|
120 |
+
async stopRecording() {
|
121 |
+
return new Promise((resolve, reject) => {
|
122 |
+
try {
|
123 |
+
const recordingDuration = Date.now() - this.state.startTime;
|
124 |
+
|
125 |
+
// Check if recording meets minimum duration
|
126 |
+
if (recordingDuration < this.config.minRecordingTime) {
|
127 |
+
throw new Error('Recording too short');
|
128 |
+
}
|
129 |
+
|
130 |
+
this.state.recorder.onstop = async () => {
|
131 |
+
try {
|
132 |
+
const audioBlob = await this.processAudio();
|
133 |
+
resolve(audioBlob);
|
134 |
+
} catch (error) {
|
135 |
+
reject(error);
|
136 |
+
}
|
137 |
+
};
|
138 |
+
|
139 |
+
// Stop recording and clean up
|
140 |
+
this.state.recorder.stop();
|
141 |
+
this.state.stream.getTracks().forEach(track => track.stop());
|
142 |
+
this.state.isRecording = false;
|
143 |
+
|
144 |
+
} catch (error) {
|
145 |
+
reject(error);
|
146 |
+
}
|
147 |
+
});
|
148 |
+
}
|
149 |
+
|
150 |
+
/**
|
151 |
+
* Process recorded audio chunks into a single blob
|
152 |
+
* @returns {Promise<Blob>}
|
153 |
+
*/
|
154 |
+
async processAudio() {
|
155 |
+
try {
|
156 |
+
// Combine audio chunks into a single blob
|
157 |
+
const audioBlob = new Blob(this.state.audioChunks, { type: 'audio/webm;codecs=opus' });
|
158 |
+
|
159 |
+
// Convert to proper format for ElevenLabs API
|
160 |
+
const arrayBuffer = await audioBlob.arrayBuffer();
|
161 |
+
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
162 |
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
163 |
+
|
164 |
+
// Create WAV format buffer
|
165 |
+
const wavBuffer = this.createWAVBuffer(audioBuffer);
|
166 |
+
|
167 |
+
return new Blob([wavBuffer], { type: 'audio/wav' });
|
168 |
+
} catch (error) {
|
169 |
+
console.error('Error processing audio:', error);
|
170 |
+
throw new Error('Failed to process audio');
|
171 |
+
}
|
172 |
+
}
|
173 |
+
|
174 |
+
/**
|
175 |
+
* Create WAV buffer from audio buffer
|
176 |
+
* @param {AudioBuffer} audioBuffer
|
177 |
+
* @returns {ArrayBuffer}
|
178 |
+
*/
|
179 |
+
createWAVBuffer(audioBuffer) {
|
180 |
+
const numChannels = audioBuffer.numberOfChannels;
|
181 |
+
const length = audioBuffer.length * numChannels * 2;
|
182 |
+
const buffer = new ArrayBuffer(44 + length);
|
183 |
+
const view = new DataView(buffer);
|
184 |
+
|
185 |
+
// Write WAV header
|
186 |
+
this.writeWAVHeader(view, length, numChannels, audioBuffer.sampleRate);
|
187 |
+
|
188 |
+
// Write audio data
|
189 |
+
const channels = [];
|
190 |
+
for (let i = 0; i < numChannels; i++) {
|
191 |
+
channels.push(audioBuffer.getChannelData(i));
|
192 |
+
}
|
193 |
+
|
194 |
+
let offset = 44;
|
195 |
+
for (let i = 0; i < audioBuffer.length; i++) {
|
196 |
+
for (let channel = 0; channel < numChannels; channel++) {
|
197 |
+
const sample = Math.max(-1, Math.min(1, channels[channel][i]));
|
198 |
+
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
|
199 |
+
offset += 2;
|
200 |
+
}
|
201 |
+
}
|
202 |
+
|
203 |
+
return buffer;
|
204 |
+
}
|
205 |
+
|
206 |
+
/**
|
207 |
+
* Write WAV header to DataView
|
208 |
+
* @param {DataView} view
|
209 |
+
* @param {number} length
|
210 |
+
* @param {number} numChannels
|
211 |
+
* @param {number} sampleRate
|
212 |
+
*/
|
213 |
+
writeWAVHeader(view, length, numChannels, sampleRate) {
|
214 |
+
// RIFF identifier
|
215 |
+
this.writeString(view, 0, 'RIFF');
|
216 |
+
// RIFF chunk length
|
217 |
+
view.setUint32(4, 36 + length, true);
|
218 |
+
// RIFF type
|
219 |
+
this.writeString(view, 8, 'WAVE');
|
220 |
+
// Format chunk identifier
|
221 |
+
this.writeString(view, 12, 'fmt ');
|
222 |
+
// Format chunk length
|
223 |
+
view.setUint32(16, 16, true);
|
224 |
+
// Sample format (raw)
|
225 |
+
view.setUint16(20, 1, true);
|
226 |
+
// Channel count
|
227 |
+
view.setUint16(22, numChannels, true);
|
228 |
+
// Sample rate
|
229 |
+
view.setUint32(24, sampleRate, true);
|
230 |
+
// Byte rate (sample rate * block align)
|
231 |
+
view.setUint32(28, sampleRate * numChannels * 2, true);
|
232 |
+
// Block align (channel count * bytes per sample)
|
233 |
+
view.setUint16(32, numChannels * 2, true);
|
234 |
+
// Bits per sample
|
235 |
+
view.setUint16(34, 16, true);
|
236 |
+
// Data chunk identifier
|
237 |
+
this.writeString(view, 36, 'data');
|
238 |
+
// Data chunk length
|
239 |
+
view.setUint32(40, length, true);
|
240 |
+
}
|
241 |
+
|
242 |
+
/**
|
243 |
+
* Write string to DataView
|
244 |
+
* @param {DataView} view
|
245 |
+
* @param {number} offset
|
246 |
+
* @param {string} string
|
247 |
+
*/
|
248 |
+
writeString(view, offset, string) {
|
249 |
+
for (let i = 0; i < string.length; i++) {
|
250 |
+
view.setUint8(offset + i, string.charCodeAt(i));
|
251 |
+
}
|
252 |
+
}
|
253 |
+
|
254 |
+
/**
|
255 |
+
* Draw audio visualization
|
256 |
+
*/
|
257 |
+
drawVisualization() {
|
258 |
+
if (!this.state.isRecording || !this.visualizer.canvasContext) return;
|
259 |
+
|
260 |
+
requestAnimationFrame(() => this.drawVisualization());
|
261 |
+
|
262 |
+
// Get frequency data
|
263 |
+
this.state.analyser.getByteFrequencyData(this.visualizer.dataArray);
|
264 |
+
|
265 |
+
// Clear canvas
|
266 |
+
this.visualizer.canvasContext.fillStyle = 'rgb(10, 10, 10)';
|
267 |
+
this.visualizer.canvasContext.fillRect(0, 0, this.visualizer.width, this.visualizer.height);
|
268 |
+
|
269 |
+
// Draw frequency bars
|
270 |
+
const barWidth = (this.visualizer.width / this.visualizer.bufferLength) * 2.5;
|
271 |
+
let barHeight;
|
272 |
+
let x = 0;
|
273 |
+
|
274 |
+
for (let i = 0; i < this.visualizer.bufferLength; i++) {
|
275 |
+
barHeight = (this.visualizer.dataArray[i] / 255) * this.visualizer.height;
|
276 |
+
|
277 |
+
this.visualizer.canvasContext.fillStyle = `rgb(${barHeight + 100},50,50)`;
|
278 |
+
this.visualizer.canvasContext.fillRect(
|
279 |
+
x,
|
280 |
+
this.visualizer.height - barHeight,
|
281 |
+
barWidth,
|
282 |
+
barHeight
|
283 |
+
);
|
284 |
+
|
285 |
+
x += barWidth + 1;
|
286 |
+
}
|
287 |
+
}
|
288 |
+
|
289 |
+
/**
|
290 |
+
* Clean up audio resources
|
291 |
+
*/
|
292 |
+
cleanup() {
|
293 |
+
if (this.state.stream) {
|
294 |
+
this.state.stream.getTracks().forEach(track => track.stop());
|
295 |
+
}
|
296 |
+
this.state.audioChunks = [];
|
297 |
+
this.state.isRecording = false;
|
298 |
+
}
|
299 |
+
}
|
300 |
+
|
301 |
+
// Export the AudioManager class
|
302 |
+
export default AudioManager;
|
static/js/background.js
ADDED
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Import Three.js library (already included in index.html)
|
2 |
+
// const THREE = window.THREE;
|
3 |
+
|
4 |
+
class Background {
|
5 |
+
constructor() {
|
6 |
+
// Initialize core Three.js components
|
7 |
+
this.scene = new THREE.Scene();
|
8 |
+
this.camera = new THREE.PerspectiveCamera(
|
9 |
+
75, // Field of view
|
10 |
+
window.innerWidth / window.innerHeight, // Aspect ratio
|
11 |
+
0.1, // Near plane
|
12 |
+
1000 // Far plane
|
13 |
+
);
|
14 |
+
|
15 |
+
// Setup renderer with transparency and anti-aliasing
|
16 |
+
this.renderer = new THREE.WebGLRenderer({
|
17 |
+
canvas: document.querySelector('#webgl-background'),
|
18 |
+
alpha: true,
|
19 |
+
antialias: true
|
20 |
+
});
|
21 |
+
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
22 |
+
this.renderer.setClearColor(0x0a0a0a, 1); // Dark background color
|
23 |
+
|
24 |
+
// Initialize particle system properties
|
25 |
+
this.particles = [];
|
26 |
+
this.particleCount = 100;
|
27 |
+
this.particleGeometry = new THREE.BufferGeometry();
|
28 |
+
this.particleMaterial = new THREE.PointsMaterial({
|
29 |
+
size: 2,
|
30 |
+
color: 0xffffff,
|
31 |
+
transparent: true,
|
32 |
+
opacity: 0.5,
|
33 |
+
blending: THREE.AdditiveBlending
|
34 |
+
});
|
35 |
+
|
36 |
+
// Set up camera position
|
37 |
+
this.camera.position.z = 100;
|
38 |
+
|
39 |
+
// Initialize the background
|
40 |
+
this.init();
|
41 |
+
|
42 |
+
// Bind event listeners
|
43 |
+
this.bindEvents();
|
44 |
+
}
|
45 |
+
|
46 |
+
init() {
|
47 |
+
// Create particle positions array
|
48 |
+
const positions = new Float32Array(this.particleCount * 3);
|
49 |
+
|
50 |
+
// Generate random positions for particles
|
51 |
+
for (let i = 0; i < this.particleCount; i++) {
|
52 |
+
const i3 = i * 3; // Index for x, y, z coordinates
|
53 |
+
positions[i3] = (Math.random() - 0.5) * window.innerWidth; // X coordinate
|
54 |
+
positions[i3 + 1] = (Math.random() - 0.5) * window.innerHeight; // Y coordinate
|
55 |
+
positions[i3 + 2] = (Math.random() - 0.5) * 500; // Z coordinate
|
56 |
+
|
57 |
+
// Store particle data for animation
|
58 |
+
this.particles.push({
|
59 |
+
velocity: (Math.random() - 0.5) * 0.2,
|
60 |
+
baseX: positions[i3],
|
61 |
+
baseY: positions[i3 + 1]
|
62 |
+
});
|
63 |
+
}
|
64 |
+
|
65 |
+
// Set particle positions in geometry
|
66 |
+
this.particleGeometry.setAttribute(
|
67 |
+
'position',
|
68 |
+
new THREE.BufferAttribute(positions, 3)
|
69 |
+
);
|
70 |
+
|
71 |
+
// Create particle system and add to scene
|
72 |
+
this.particleSystem = new THREE.Points(
|
73 |
+
this.particleGeometry,
|
74 |
+
this.particleMaterial
|
75 |
+
);
|
76 |
+
this.scene.add(this.particleSystem);
|
77 |
+
|
78 |
+
// Start animation loop
|
79 |
+
this.animate();
|
80 |
+
}
|
81 |
+
|
82 |
+
// Handle window resize events
|
83 |
+
bindEvents() {
|
84 |
+
window.addEventListener('resize', () => {
|
85 |
+
// Update camera aspect ratio and projection matrix
|
86 |
+
this.camera.aspect = window.innerWidth / window.innerHeight;
|
87 |
+
this.camera.updateProjectionMatrix();
|
88 |
+
|
89 |
+
// Update renderer size
|
90 |
+
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
91 |
+
});
|
92 |
+
}
|
93 |
+
|
94 |
+
// Animation loop for particle movement
|
95 |
+
animate() {
|
96 |
+
requestAnimationFrame(() => this.animate());
|
97 |
+
|
98 |
+
const positions = this.particleGeometry.attributes.position.array;
|
99 |
+
const time = Date.now() * 0.0005;
|
100 |
+
|
101 |
+
// Update particle positions with wave-like motion
|
102 |
+
for (let i = 0; i < this.particleCount; i++) {
|
103 |
+
const i3 = i * 3;
|
104 |
+
const particle = this.particles[i];
|
105 |
+
|
106 |
+
// Create wave-like motion using sine waves
|
107 |
+
positions[i3] = particle.baseX + Math.sin(time + i) * 2;
|
108 |
+
positions[i3 + 1] = particle.baseY + Math.cos(time + i) * 2;
|
109 |
+
|
110 |
+
// Add slight drift to z-position
|
111 |
+
positions[i3 + 2] += particle.velocity;
|
112 |
+
|
113 |
+
// Reset particles that drift too far
|
114 |
+
if (Math.abs(positions[i3 + 2]) > 250) {
|
115 |
+
positions[i3 + 2] = -250;
|
116 |
+
}
|
117 |
+
}
|
118 |
+
|
119 |
+
// Mark particle positions for update
|
120 |
+
this.particleGeometry.attributes.position.needsUpdate = true;
|
121 |
+
|
122 |
+
// Add subtle camera movement
|
123 |
+
this.camera.position.x = Math.sin(time) * 10;
|
124 |
+
this.camera.position.y = Math.cos(time) * 10;
|
125 |
+
this.camera.lookAt(this.scene.position);
|
126 |
+
|
127 |
+
// Render the scene
|
128 |
+
this.renderer.render(this.scene, this.camera);
|
129 |
+
}
|
130 |
+
|
131 |
+
// Method to add dramatic effects during game events
|
132 |
+
addDramaticEffect(type) {
|
133 |
+
switch(type) {
|
134 |
+
case 'impostor_reveal':
|
135 |
+
// Create a dramatic red flash effect
|
136 |
+
this.particleMaterial.color.setHex(0xff0000);
|
137 |
+
setTimeout(() => {
|
138 |
+
this.particleMaterial.color.setHex(0xffffff);
|
139 |
+
}, 1000);
|
140 |
+
break;
|
141 |
+
|
142 |
+
case 'round_start':
|
143 |
+
// Increase particle movement temporarily
|
144 |
+
const originalVelocities = this.particles.map(p => p.velocity);
|
145 |
+
this.particles.forEach(p => p.velocity *= 2);
|
146 |
+
setTimeout(() => {
|
147 |
+
this.particles.forEach((p, i) => p.velocity = originalVelocities[i]);
|
148 |
+
}, 2000);
|
149 |
+
break;
|
150 |
+
|
151 |
+
case 'voting':
|
152 |
+
// Create a pulsing effect
|
153 |
+
const pulseAnimation = () => {
|
154 |
+
this.particleMaterial.size = 2 + Math.sin(Date.now() * 0.005) * 1;
|
155 |
+
};
|
156 |
+
const pulseInterval = setInterval(pulseAnimation, 16);
|
157 |
+
setTimeout(() => {
|
158 |
+
clearInterval(pulseInterval);
|
159 |
+
this.particleMaterial.size = 2;
|
160 |
+
}, 3000);
|
161 |
+
break;
|
162 |
+
}
|
163 |
+
}
|
164 |
+
}
|
165 |
+
|
166 |
+
// Initialize the background when the DOM is loaded
|
167 |
+
document.addEventListener('DOMContentLoaded', () => {
|
168 |
+
const background = new Background();
|
169 |
+
|
170 |
+
// Expose background instance for game events
|
171 |
+
window.gameBackground = background;
|
172 |
+
});
|
173 |
+
|
174 |
+
// Export the Background class for potential module usage
|
175 |
+
export default Background;
|
static/js/game.js
ADDED
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Initialize Socket.IO connection with automatic reconnection
|
2 |
+
const socket = io({
|
3 |
+
reconnection: true,
|
4 |
+
reconnectionAttempts: 5,
|
5 |
+
reconnectionDelay: 1000
|
6 |
+
});
|
7 |
+
|
8 |
+
class Game {
|
9 |
+
constructor() {
|
10 |
+
// Core game state
|
11 |
+
this.state = {
|
12 |
+
gameId: null,
|
13 |
+
currentPhase: 'landing',
|
14 |
+
playerId: null,
|
15 |
+
players: [],
|
16 |
+
currentQuestion: null,
|
17 |
+
isRecording: false,
|
18 |
+
recordingTime: 30, // seconds
|
19 |
+
listeningTime: 60, // seconds
|
20 |
+
votingTime: 60, // seconds
|
21 |
+
recordings: new Map(),
|
22 |
+
votes: new Map(),
|
23 |
+
impostor: null
|
24 |
+
};
|
25 |
+
|
26 |
+
// Audio recording configuration
|
27 |
+
this.audioConfig = {
|
28 |
+
mediaRecorder: null,
|
29 |
+
audioChunks: [],
|
30 |
+
stream: null
|
31 |
+
};
|
32 |
+
|
33 |
+
// Initialize the game
|
34 |
+
this.initializeEventListeners();
|
35 |
+
this.bindUIElements();
|
36 |
+
}
|
37 |
+
|
38 |
+
// Initialize all event listeners for the game
|
39 |
+
initializeEventListeners() {
|
40 |
+
// Socket event listeners
|
41 |
+
socket.on('connect', () => this.handleConnection());
|
42 |
+
socket.on('game_created', (data) => this.handleGameCreated(data));
|
43 |
+
socket.on('player_joined', (data) => this.handlePlayerJoined(data));
|
44 |
+
socket.on('round_start', (data) => this.handleRoundStart(data));
|
45 |
+
socket.on('round_result', (data) => this.handleRoundResult(data));
|
46 |
+
|
47 |
+
// UI event listeners
|
48 |
+
document.getElementById('start-button')?.addEventListener('click', () => this.startGame());
|
49 |
+
document.getElementById('add-player-button')?.addEventListener('click', () => this.addPlayer());
|
50 |
+
}
|
51 |
+
|
52 |
+
// Bind UI elements and initialize their event handlers
|
53 |
+
bindUIElements() {
|
54 |
+
// Bind all necessary UI elements
|
55 |
+
const uiElements = {
|
56 |
+
gameContainer: document.getElementById('game-container'),
|
57 |
+
landingPage: document.getElementById('landing-page'),
|
58 |
+
setupPage: document.getElementById('setup-page'),
|
59 |
+
gamePage: document.getElementById('game-page'),
|
60 |
+
questionDisplay: document.getElementById('question-display'),
|
61 |
+
timerDisplay: document.getElementById('timer-display'),
|
62 |
+
recordButton: document.getElementById('record-button'),
|
63 |
+
playerList: document.getElementById('player-list'),
|
64 |
+
voteButtons: document.querySelectorAll('.vote-button')
|
65 |
+
};
|
66 |
+
|
67 |
+
// Store UI elements in the class
|
68 |
+
this.ui = uiElements;
|
69 |
+
}
|
70 |
+
|
71 |
+
// Handle initial connection to the server
|
72 |
+
handleConnection() {
|
73 |
+
console.log('Connected to server');
|
74 |
+
this.showPage('landing');
|
75 |
+
}
|
76 |
+
|
77 |
+
// Create a new game session
|
78 |
+
async createGame() {
|
79 |
+
try {
|
80 |
+
socket.emit('create_game');
|
81 |
+
} catch (error) {
|
82 |
+
this.handleError('Failed to create game');
|
83 |
+
}
|
84 |
+
}
|
85 |
+
|
86 |
+
// Handle successful game creation
|
87 |
+
handleGameCreated(data) {
|
88 |
+
this.state.gameId = data.gameId;
|
89 |
+
this.showPage('setup');
|
90 |
+
}
|
91 |
+
|
92 |
+
// Add a new player to the game
|
93 |
+
async addPlayer() {
|
94 |
+
if (this.state.players.length >= 5) {
|
95 |
+
this.handleError('Maximum player limit reached');
|
96 |
+
return;
|
97 |
+
}
|
98 |
+
|
99 |
+
const playerName = prompt('Enter player name:');
|
100 |
+
if (!playerName) return;
|
101 |
+
|
102 |
+
socket.emit('join_game', {
|
103 |
+
gameId: this.state.gameId,
|
104 |
+
playerName: playerName
|
105 |
+
});
|
106 |
+
}
|
107 |
+
|
108 |
+
// Handle new player joining the game
|
109 |
+
handlePlayerJoined(data) {
|
110 |
+
this.state.players.push({
|
111 |
+
id: data.playerId,
|
112 |
+
name: data.playerName
|
113 |
+
});
|
114 |
+
this.updatePlayerList();
|
115 |
+
}
|
116 |
+
|
117 |
+
// Update the player list in the UI
|
118 |
+
updatePlayerList() {
|
119 |
+
if (!this.ui.playerList) return;
|
120 |
+
|
121 |
+
this.ui.playerList.innerHTML = '';
|
122 |
+
this.state.players.forEach(player => {
|
123 |
+
const playerElement = document.createElement('div');
|
124 |
+
playerElement.className = 'player-avatar';
|
125 |
+
playerElement.textContent = player.id;
|
126 |
+
this.ui.playerList.appendChild(playerElement);
|
127 |
+
});
|
128 |
+
}
|
129 |
+
|
130 |
+
// Start the game
|
131 |
+
async startGame() {
|
132 |
+
if (this.state.players.length < 3) {
|
133 |
+
this.handleError('Need at least 3 players to start');
|
134 |
+
return;
|
135 |
+
}
|
136 |
+
|
137 |
+
try {
|
138 |
+
const response = await fetch('/api/start_game', {
|
139 |
+
method: 'POST',
|
140 |
+
headers: {
|
141 |
+
'Content-Type': 'application/json'
|
142 |
+
},
|
143 |
+
body: JSON.stringify({
|
144 |
+
gameId: this.state.gameId
|
145 |
+
})
|
146 |
+
});
|
147 |
+
|
148 |
+
const data = await response.json();
|
149 |
+
if (data.status === 'success') {
|
150 |
+
this.handleRoundStart(data);
|
151 |
+
}
|
152 |
+
} catch (error) {
|
153 |
+
this.handleError('Failed to start game');
|
154 |
+
}
|
155 |
+
}
|
156 |
+
|
157 |
+
// Handle the start of a new round
|
158 |
+
handleRoundStart(data) {
|
159 |
+
this.state.currentQuestion = data.question;
|
160 |
+
this.state.currentPhase = 'recording';
|
161 |
+
this.showPage('game');
|
162 |
+
this.updateQuestionDisplay();
|
163 |
+
this.startTimer(this.state.recordingTime, () => this.endRecordingPhase());
|
164 |
+
}
|
165 |
+
|
166 |
+
// Start audio recording
|
167 |
+
async startRecording() {
|
168 |
+
try {
|
169 |
+
this.audioConfig.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
170 |
+
this.audioConfig.mediaRecorder = new MediaRecorder(this.audioConfig.stream);
|
171 |
+
this.audioConfig.audioChunks = [];
|
172 |
+
|
173 |
+
this.audioConfig.mediaRecorder.ondataavailable = (event) => {
|
174 |
+
this.audioConfig.audioChunks.push(event.data);
|
175 |
+
};
|
176 |
+
|
177 |
+
this.audioConfig.mediaRecorder.onstop = () => {
|
178 |
+
this.submitRecording();
|
179 |
+
};
|
180 |
+
|
181 |
+
this.audioConfig.mediaRecorder.start();
|
182 |
+
this.state.isRecording = true;
|
183 |
+
this.updateRecordButton();
|
184 |
+
} catch (error) {
|
185 |
+
this.handleError('Failed to start recording');
|
186 |
+
}
|
187 |
+
}
|
188 |
+
|
189 |
+
// Stop audio recording
|
190 |
+
stopRecording() {
|
191 |
+
if (this.audioConfig.mediaRecorder && this.state.isRecording) {
|
192 |
+
this.audioConfig.mediaRecorder.stop();
|
193 |
+
this.audioConfig.stream.getTracks().forEach(track => track.stop());
|
194 |
+
this.state.isRecording = false;
|
195 |
+
this.updateRecordButton();
|
196 |
+
}
|
197 |
+
}
|
198 |
+
|
199 |
+
// Submit recording to server
|
200 |
+
async submitRecording() {
|
201 |
+
const audioBlob = new Blob(this.audioConfig.audioChunks, { type: 'audio/wav' });
|
202 |
+
const formData = new FormData();
|
203 |
+
formData.append('audio', audioBlob);
|
204 |
+
formData.append('gameId', this.state.gameId);
|
205 |
+
formData.append('playerId', this.state.playerId);
|
206 |
+
|
207 |
+
try {
|
208 |
+
const response = await fetch('/api/submit_recording', {
|
209 |
+
method: 'POST',
|
210 |
+
body: formData
|
211 |
+
});
|
212 |
+
|
213 |
+
const data = await response.json();
|
214 |
+
if (data.status === 'success') {
|
215 |
+
this.state.recordings.set(this.state.playerId, data.recordingUrl);
|
216 |
+
}
|
217 |
+
} catch (error) {
|
218 |
+
this.handleError('Failed to submit recording');
|
219 |
+
}
|
220 |
+
}
|
221 |
+
|
222 |
+
// Start the timer for a game phase
|
223 |
+
startTimer(duration, callback) {
|
224 |
+
let timeLeft = duration;
|
225 |
+
this.updateTimerDisplay(timeLeft);
|
226 |
+
|
227 |
+
this.timer = setInterval(() => {
|
228 |
+
timeLeft--;
|
229 |
+
this.updateTimerDisplay(timeLeft);
|
230 |
+
|
231 |
+
if (timeLeft <= 0) {
|
232 |
+
clearInterval(this.timer);
|
233 |
+
if (callback) callback();
|
234 |
+
}
|
235 |
+
}, 1000);
|
236 |
+
}
|
237 |
+
|
238 |
+
// Update the timer display
|
239 |
+
updateTimerDisplay(timeLeft) {
|
240 |
+
if (this.ui.timerDisplay) {
|
241 |
+
const minutes = Math.floor(timeLeft / 60);
|
242 |
+
const seconds = timeLeft % 60;
|
243 |
+
this.ui.timerDisplay.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
244 |
+
}
|
245 |
+
}
|
246 |
+
|
247 |
+
// End the recording phase and move to listening phase
|
248 |
+
endRecordingPhase() {
|
249 |
+
if (this.state.isRecording) {
|
250 |
+
this.stopRecording();
|
251 |
+
}
|
252 |
+
this.state.currentPhase = 'listening';
|
253 |
+
this.startListeningPhase();
|
254 |
+
}
|
255 |
+
|
256 |
+
// Start the listening phase
|
257 |
+
startListeningPhase() {
|
258 |
+
this.showPage('listening');
|
259 |
+
this.loadRecordings();
|
260 |
+
this.startTimer(this.state.listeningTime, () => this.startVotingPhase());
|
261 |
+
}
|
262 |
+
|
263 |
+
// Load all player recordings
|
264 |
+
async loadRecordings() {
|
265 |
+
// Implementation for loading and playing recordings
|
266 |
+
// This would integrate with the audio playback UI
|
267 |
+
}
|
268 |
+
|
269 |
+
// Start the voting phase
|
270 |
+
startVotingPhase() {
|
271 |
+
this.state.currentPhase = 'voting';
|
272 |
+
this.showPage('voting');
|
273 |
+
this.startTimer(this.state.votingTime, () => this.endVotingPhase());
|
274 |
+
}
|
275 |
+
|
276 |
+
// Submit a vote
|
277 |
+
submitVote(votedPlayerId) {
|
278 |
+
socket.emit('submit_vote', {
|
279 |
+
gameId: this.state.gameId,
|
280 |
+
voterId: this.state.playerId,
|
281 |
+
votedPlayerId: votedPlayerId
|
282 |
+
});
|
283 |
+
}
|
284 |
+
|
285 |
+
// Handle the round results
|
286 |
+
handleRoundResult(data) {
|
287 |
+
this.showResults(data);
|
288 |
+
// Trigger dramatic background effect
|
289 |
+
window.gameBackground?.addDramaticEffect('impostor_reveal');
|
290 |
+
}
|
291 |
+
|
292 |
+
// Show the results page
|
293 |
+
showResults(data) {
|
294 |
+
this.showPage('results');
|
295 |
+
// Implementation for displaying round results
|
296 |
+
}
|
297 |
+
|
298 |
+
// Switch between game pages
|
299 |
+
showPage(pageName) {
|
300 |
+
const pages = document.querySelectorAll('.game-page');
|
301 |
+
pages.forEach(page => {
|
302 |
+
page.classList.remove('active');
|
303 |
+
if (page.id === `${pageName}-page`) {
|
304 |
+
page.classList.add('active');
|
305 |
+
}
|
306 |
+
});
|
307 |
+
}
|
308 |
+
|
309 |
+
// Handle errors
|
310 |
+
handleError(message) {
|
311 |
+
const errorElement = document.createElement('div');
|
312 |
+
errorElement.className = 'error-message';
|
313 |
+
errorElement.textContent = message;
|
314 |
+
this.ui.gameContainer.appendChild(errorElement);
|
315 |
+
setTimeout(() => errorElement.remove(), 3000);
|
316 |
+
}
|
317 |
+
}
|
318 |
+
|
319 |
+
// Initialize the game when the DOM is loaded
|
320 |
+
document.addEventListener('DOMContentLoaded', () => {
|
321 |
+
const game = new Game();
|
322 |
+
// Expose game instance for debugging
|
323 |
+
window.game = game;
|
324 |
+
});
|
325 |
+
|
326 |
+
export default Game;
|
static/js/ui.js
ADDED
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
class GameUI {
|
2 |
+
constructor() {
|
3 |
+
// Initialize UI state and configurations
|
4 |
+
this.state = {
|
5 |
+
currentPage: 'landing',
|
6 |
+
isAnimating: false,
|
7 |
+
darkMode: true,
|
8 |
+
animationDuration: 300
|
9 |
+
};
|
10 |
+
|
11 |
+
// Store references to frequently accessed DOM elements
|
12 |
+
this.elements = {
|
13 |
+
pages: {},
|
14 |
+
buttons: {},
|
15 |
+
containers: {},
|
16 |
+
overlays: {}
|
17 |
+
};
|
18 |
+
|
19 |
+
// Initialize the UI
|
20 |
+
this.initialize();
|
21 |
+
}
|
22 |
+
|
23 |
+
async initialize() {
|
24 |
+
// Wait for DOM to be fully loaded
|
25 |
+
if (document.readyState === 'loading') {
|
26 |
+
document.addEventListener('DOMContentLoaded', () => this.setupUI());
|
27 |
+
} else {
|
28 |
+
this.setupUI();
|
29 |
+
}
|
30 |
+
}
|
31 |
+
|
32 |
+
setupUI() {
|
33 |
+
// Cache all important DOM elements
|
34 |
+
this.cacheElements();
|
35 |
+
// Set up event listeners
|
36 |
+
this.setupEventListeners();
|
37 |
+
// Initialize the landing page
|
38 |
+
this.showPage('landing');
|
39 |
+
}
|
40 |
+
|
41 |
+
cacheElements() {
|
42 |
+
// Cache all page elements
|
43 |
+
['landing', 'setup', 'game', 'recording', 'listening', 'voting', 'results'].forEach(pageId => {
|
44 |
+
this.elements.pages[pageId] = document.getElementById(`${pageId}-page`);
|
45 |
+
});
|
46 |
+
|
47 |
+
// Cache all button elements
|
48 |
+
this.elements.buttons = {
|
49 |
+
play: document.getElementById('play-button'),
|
50 |
+
addPlayer: document.getElementById('add-player-button'),
|
51 |
+
start: document.getElementById('start-button'),
|
52 |
+
record: document.getElementById('record-button'),
|
53 |
+
settings: document.getElementById('settings-button')
|
54 |
+
};
|
55 |
+
|
56 |
+
// Cache container elements
|
57 |
+
this.elements.containers = {
|
58 |
+
playerList: document.getElementById('player-list'),
|
59 |
+
questionDisplay: document.getElementById('question-display'),
|
60 |
+
timerDisplay: document.getElementById('timer-display'),
|
61 |
+
recordingVisualizer: document.getElementById('recording-visualizer'),
|
62 |
+
votingOptions: document.getElementById('voting-options')
|
63 |
+
};
|
64 |
+
|
65 |
+
// Cache overlay elements
|
66 |
+
this.elements.overlays = {
|
67 |
+
loading: document.getElementById('loading-overlay'),
|
68 |
+
error: document.getElementById('error-overlay'),
|
69 |
+
settings: document.getElementById('settings-overlay')
|
70 |
+
};
|
71 |
+
}
|
72 |
+
|
73 |
+
setupEventListeners() {
|
74 |
+
// Set up button click handlers
|
75 |
+
Object.entries(this.elements.buttons).forEach(([key, button]) => {
|
76 |
+
if (button) {
|
77 |
+
button.addEventListener('click', () => this.handleButtonClick(key));
|
78 |
+
}
|
79 |
+
});
|
80 |
+
|
81 |
+
// Set up keyboard shortcuts
|
82 |
+
document.addEventListener('keydown', (e) => this.handleKeyPress(e));
|
83 |
+
|
84 |
+
// Set up settings toggle
|
85 |
+
if (this.elements.buttons.settings) {
|
86 |
+
this.elements.buttons.settings.addEventListener('click', () => this.toggleSettings());
|
87 |
+
}
|
88 |
+
}
|
89 |
+
|
90 |
+
handleButtonClick(buttonType) {
|
91 |
+
switch (buttonType) {
|
92 |
+
case 'play':
|
93 |
+
this.transitionToPage('setup');
|
94 |
+
break;
|
95 |
+
case 'addPlayer':
|
96 |
+
window.game.addPlayer();
|
97 |
+
break;
|
98 |
+
case 'start':
|
99 |
+
window.game.startGame();
|
100 |
+
break;
|
101 |
+
case 'record':
|
102 |
+
this.toggleRecording();
|
103 |
+
break;
|
104 |
+
default:
|
105 |
+
console.warn(`Unhandled button type: ${buttonType}`);
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
async transitionToPage(pageName) {
|
110 |
+
if (this.state.isAnimating || this.state.currentPage === pageName) return;
|
111 |
+
|
112 |
+
this.state.isAnimating = true;
|
113 |
+
|
114 |
+
// Fade out current page
|
115 |
+
const currentPage = this.elements.pages[this.state.currentPage];
|
116 |
+
if (currentPage) {
|
117 |
+
await this.animateElement(currentPage, 'fadeOut');
|
118 |
+
currentPage.classList.remove('active');
|
119 |
+
}
|
120 |
+
|
121 |
+
// Update state and fade in new page
|
122 |
+
this.state.currentPage = pageName;
|
123 |
+
const newPage = this.elements.pages[pageName];
|
124 |
+
if (newPage) {
|
125 |
+
newPage.classList.add('active');
|
126 |
+
await this.animateElement(newPage, 'fadeIn');
|
127 |
+
}
|
128 |
+
|
129 |
+
this.state.isAnimating = false;
|
130 |
+
}
|
131 |
+
|
132 |
+
async animateElement(element, animation) {
|
133 |
+
return new Promise(resolve => {
|
134 |
+
element.classList.add(animation);
|
135 |
+
setTimeout(() => {
|
136 |
+
element.classList.remove(animation);
|
137 |
+
resolve();
|
138 |
+
}, this.state.animationDuration);
|
139 |
+
});
|
140 |
+
}
|
141 |
+
|
142 |
+
updatePlayerList(players) {
|
143 |
+
if (!this.elements.containers.playerList) return;
|
144 |
+
|
145 |
+
const playerList = this.elements.containers.playerList;
|
146 |
+
playerList.innerHTML = '';
|
147 |
+
|
148 |
+
players.forEach(player => {
|
149 |
+
const playerElement = document.createElement('div');
|
150 |
+
playerElement.className = 'player-avatar';
|
151 |
+
|
152 |
+
// Create avatar circle
|
153 |
+
const avatarCircle = document.createElement('div');
|
154 |
+
avatarCircle.className = 'avatar-circle';
|
155 |
+
avatarCircle.textContent = player.id;
|
156 |
+
|
157 |
+
// Create player name
|
158 |
+
const playerName = document.createElement('div');
|
159 |
+
playerName.className = 'player-name';
|
160 |
+
playerName.textContent = player.name;
|
161 |
+
|
162 |
+
playerElement.appendChild(avatarCircle);
|
163 |
+
playerElement.appendChild(playerName);
|
164 |
+
playerList.appendChild(playerElement);
|
165 |
+
});
|
166 |
+
}
|
167 |
+
|
168 |
+
updateTimer(timeLeft) {
|
169 |
+
if (!this.elements.containers.timerDisplay) return;
|
170 |
+
|
171 |
+
const minutes = Math.floor(timeLeft / 60);
|
172 |
+
const seconds = timeLeft % 60;
|
173 |
+
this.elements.containers.timerDisplay.textContent =
|
174 |
+
`${minutes}:${seconds.toString().padStart(2, '0')}`;
|
175 |
+
|
176 |
+
// Add warning class when time is running low
|
177 |
+
if (timeLeft <= 10) {
|
178 |
+
this.elements.containers.timerDisplay.classList.add('warning');
|
179 |
+
}
|
180 |
+
}
|
181 |
+
|
182 |
+
showQuestion(question) {
|
183 |
+
if (!this.elements.containers.questionDisplay) return;
|
184 |
+
|
185 |
+
const questionElement = this.elements.containers.questionDisplay;
|
186 |
+
questionElement.textContent = question;
|
187 |
+
|
188 |
+
// Animate question appearance
|
189 |
+
this.animateElement(questionElement, 'slideIn');
|
190 |
+
}
|
191 |
+
|
192 |
+
toggleRecording(isRecording) {
|
193 |
+
if (!this.elements.buttons.record) return;
|
194 |
+
|
195 |
+
const recordButton = this.elements.buttons.record;
|
196 |
+
recordButton.classList.toggle('recording', isRecording);
|
197 |
+
recordButton.textContent = isRecording ? 'Stop Recording' : 'Start Recording';
|
198 |
+
}
|
199 |
+
|
200 |
+
showLoadingOverlay(show, message = 'Loading...') {
|
201 |
+
if (!this.elements.overlays.loading) return;
|
202 |
+
|
203 |
+
const overlay = this.elements.overlays.loading;
|
204 |
+
if (show) {
|
205 |
+
overlay.querySelector('.loading-message').textContent = message;
|
206 |
+
overlay.classList.add('active');
|
207 |
+
} else {
|
208 |
+
overlay.classList.remove('active');
|
209 |
+
}
|
210 |
+
}
|
211 |
+
|
212 |
+
showError(message, duration = 3000) {
|
213 |
+
if (!this.elements.overlays.error) return;
|
214 |
+
|
215 |
+
const errorOverlay = this.elements.overlays.error;
|
216 |
+
const errorMessage = errorOverlay.querySelector('.error-message');
|
217 |
+
|
218 |
+
errorMessage.textContent = message;
|
219 |
+
errorOverlay.classList.add('active');
|
220 |
+
|
221 |
+
setTimeout(() => {
|
222 |
+
errorOverlay.classList.remove('active');
|
223 |
+
}, duration);
|
224 |
+
}
|
225 |
+
|
226 |
+
updateVotingOptions(players, currentPlayer) {
|
227 |
+
if (!this.elements.containers.votingOptions) return;
|
228 |
+
|
229 |
+
const votingContainer = this.elements.containers.votingOptions;
|
230 |
+
votingContainer.innerHTML = '';
|
231 |
+
|
232 |
+
players.forEach(player => {
|
233 |
+
if (player.id !== currentPlayer) {
|
234 |
+
const voteButton = document.createElement('button');
|
235 |
+
voteButton.className = 'vote-button';
|
236 |
+
voteButton.dataset.playerId = player.id;
|
237 |
+
|
238 |
+
const playerCircle = document.createElement('div');
|
239 |
+
playerCircle.className = 'player-circle';
|
240 |
+
playerCircle.textContent = player.id;
|
241 |
+
|
242 |
+
voteButton.appendChild(playerCircle);
|
243 |
+
votingContainer.appendChild(voteButton);
|
244 |
+
|
245 |
+
voteButton.addEventListener('click', () => {
|
246 |
+
window.game.submitVote(player.id);
|
247 |
+
});
|
248 |
+
}
|
249 |
+
});
|
250 |
+
}
|
251 |
+
|
252 |
+
showResults(results) {
|
253 |
+
const resultsPage = this.elements.pages.results;
|
254 |
+
if (!resultsPage) return;
|
255 |
+
|
256 |
+
// Clear previous results
|
257 |
+
resultsPage.innerHTML = '';
|
258 |
+
|
259 |
+
// Create results content
|
260 |
+
const content = document.createElement('div');
|
261 |
+
content.className = 'results-content';
|
262 |
+
|
263 |
+
// Add impostor reveal
|
264 |
+
const impostorReveal = document.createElement('div');
|
265 |
+
impostorReveal.className = 'impostor-reveal';
|
266 |
+
impostorReveal.textContent = `The impostor was Player ${results.impostor}!`;
|
267 |
+
|
268 |
+
// Add voting results
|
269 |
+
const votingResults = document.createElement('div');
|
270 |
+
votingResults.className = 'voting-results';
|
271 |
+
Object.entries(results.votes).forEach(([player, vote]) => {
|
272 |
+
const voteEntry = document.createElement('div');
|
273 |
+
voteEntry.className = 'vote-entry';
|
274 |
+
voteEntry.textContent = `Player ${player} voted for Player ${vote}`;
|
275 |
+
votingResults.appendChild(voteEntry);
|
276 |
+
});
|
277 |
+
|
278 |
+
content.appendChild(impostorReveal);
|
279 |
+
content.appendChild(votingResults);
|
280 |
+
resultsPage.appendChild(content);
|
281 |
+
|
282 |
+
this.transitionToPage('results');
|
283 |
+
}
|
284 |
+
|
285 |
+
handleKeyPress(event) {
|
286 |
+
// Add keyboard shortcuts
|
287 |
+
switch (event.key) {
|
288 |
+
case 'Escape':
|
289 |
+
this.closeAllOverlays();
|
290 |
+
break;
|
291 |
+
case 'r':
|
292 |
+
if (this.state.currentPage === 'recording') {
|
293 |
+
this.toggleRecording();
|
294 |
+
}
|
295 |
+
break;
|
296 |
+
}
|
297 |
+
}
|
298 |
+
|
299 |
+
closeAllOverlays() {
|
300 |
+
Object.values(this.elements.overlays).forEach(overlay => {
|
301 |
+
if (overlay) {
|
302 |
+
overlay.classList.remove('active');
|
303 |
+
}
|
304 |
+
});
|
305 |
+
}
|
306 |
+
|
307 |
+
// Method to clean up UI resources
|
308 |
+
cleanup() {
|
309 |
+
// Remove event listeners
|
310 |
+
Object.values(this.elements.buttons).forEach(button => {
|
311 |
+
if (button) {
|
312 |
+
button.replaceWith(button.cloneNode(true));
|
313 |
+
}
|
314 |
+
});
|
315 |
+
|
316 |
+
// Clear all intervals and timeouts
|
317 |
+
this.closeAllOverlays();
|
318 |
+
}
|
319 |
+
}
|
320 |
+
|
321 |
+
// Export the GameUI class
|
322 |
+
export default GameUI;
|
templates/index.html
ADDED
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<meta name="description" content="NOT ME - A voice deduction game powered by AI">
|
7 |
+
|
8 |
+
<title>NOT ME - Voice Deduction Game</title>
|
9 |
+
|
10 |
+
<!-- Import the Pixelify Sans font for our game's unique style -->
|
11 |
+
<link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
12 |
+
|
13 |
+
<!-- Main stylesheet -->
|
14 |
+
<link rel="stylesheet" href="static/css/styles.css">
|
15 |
+
|
16 |
+
<!-- Socket.IO client for real-time communication -->
|
17 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
18 |
+
|
19 |
+
<!-- Three.js for background animations -->
|
20 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
21 |
+
</head>
|
22 |
+
<body>
|
23 |
+
<!-- WebGL background container for particle effects -->
|
24 |
+
<canvas id="webgl-background"></canvas>
|
25 |
+
|
26 |
+
<!-- Settings button that appears after game starts -->
|
27 |
+
<button id="settings-button" class="settings-button hidden">
|
28 |
+
<span class="settings-icon">⚙️</span>
|
29 |
+
</button>
|
30 |
+
|
31 |
+
<!-- Main game container -->
|
32 |
+
<div id="game-container">
|
33 |
+
<!-- Landing Page -->
|
34 |
+
<div id="landing-page" class="game-page active">
|
35 |
+
<h1 class="game-title">
|
36 |
+
NOT <span class="title-emphasis">ME</span>
|
37 |
+
</h1>
|
38 |
+
<button id="play-button" class="button primary-button">PLAY</button>
|
39 |
+
</div>
|
40 |
+
|
41 |
+
<!-- Player Setup Page -->
|
42 |
+
<div id="setup-page" class="game-page">
|
43 |
+
<h2 class="page-title">Player Setup</h2>
|
44 |
+
<div id="player-list" class="player-grid"></div>
|
45 |
+
<div class="setup-controls">
|
46 |
+
<button id="add-player-button" class="button secondary-button">Add Player</button>
|
47 |
+
<button id="start-button" class="button primary-button" disabled>Start Game</button>
|
48 |
+
</div>
|
49 |
+
</div>
|
50 |
+
|
51 |
+
<!-- Game Page (Recording Phase) -->
|
52 |
+
<div id="recording-page" class="game-page">
|
53 |
+
<div id="timer-display" class="timer"></div>
|
54 |
+
<div id="question-display" class="question-box"></div>
|
55 |
+
|
56 |
+
<!-- Recording interface -->
|
57 |
+
<div class="recording-controls">
|
58 |
+
<canvas id="recording-visualizer" class="visualizer"></canvas>
|
59 |
+
<button id="record-button" class="button record-button">Start Recording</button>
|
60 |
+
<div class="recording-time"></div>
|
61 |
+
</div>
|
62 |
+
</div>
|
63 |
+
|
64 |
+
<!-- Listening Phase -->
|
65 |
+
<div id="listening-page" class="game-page">
|
66 |
+
<div id="timer-display" class="timer"></div>
|
67 |
+
<h2 class="page-title">Listen to Responses</h2>
|
68 |
+
<div id="recordings-list" class="recordings-grid">
|
69 |
+
<!-- Recording playback buttons will be dynamically added here -->
|
70 |
+
</div>
|
71 |
+
</div>
|
72 |
+
|
73 |
+
<!-- Voting Phase -->
|
74 |
+
<div id="voting-page" class="game-page">
|
75 |
+
<div id="timer-display" class="timer"></div>
|
76 |
+
<h2 class="page-title">Vote for the Impostor</h2>
|
77 |
+
<div id="voting-options" class="voting-grid">
|
78 |
+
<!-- Voting buttons will be dynamically added here -->
|
79 |
+
</div>
|
80 |
+
</div>
|
81 |
+
|
82 |
+
<!-- Results Page -->
|
83 |
+
<div id="results-page" class="game-page">
|
84 |
+
<h2 class="page-title">Round Results</h2>
|
85 |
+
<div id="results-content" class="results-container">
|
86 |
+
<!-- Results will be dynamically added here -->
|
87 |
+
</div>
|
88 |
+
<button id="next-round-button" class="button primary-button">Next Round</button>
|
89 |
+
</div>
|
90 |
+
</div>
|
91 |
+
|
92 |
+
<!-- Overlay containers -->
|
93 |
+
<div id="loading-overlay" class="overlay">
|
94 |
+
<div class="loading-spinner"></div>
|
95 |
+
<div class="loading-message">Loading...</div>
|
96 |
+
</div>
|
97 |
+
|
98 |
+
<div id="error-overlay" class="overlay">
|
99 |
+
<div class="error-message"></div>
|
100 |
+
</div>
|
101 |
+
|
102 |
+
<div id="settings-overlay" class="overlay">
|
103 |
+
<div class="settings-panel">
|
104 |
+
<h3>Game Settings</h3>
|
105 |
+
<div class="settings-options">
|
106 |
+
<div class="setting-item">
|
107 |
+
<label for="dark-mode">Dark Mode</label>
|
108 |
+
<input type="checkbox" id="dark-mode" checked>
|
109 |
+
</div>
|
110 |
+
<div class="setting-item">
|
111 |
+
<label for="sound-effects">Sound Effects</label>
|
112 |
+
<input type="checkbox" id="sound-effects" checked>
|
113 |
+
</div>
|
114 |
+
</div>
|
115 |
+
<button id="exit-game" class="button secondary-button">Exit Game</button>
|
116 |
+
</div>
|
117 |
+
</div>
|
118 |
+
|
119 |
+
<!-- Game scripts -->
|
120 |
+
<script type="module" src="static/js/background.js"></script>
|
121 |
+
<script type="module" src="static/js/audio.js"></script>
|
122 |
+
<script type="module" src="static/js/ui.js"></script>
|
123 |
+
<script type="module" src="static/js/game.js"></script>
|
124 |
+
|
125 |
+
<!-- Audio context initialization script -->
|
126 |
+
<script>
|
127 |
+
// Initialize audio context on user interaction
|
128 |
+
document.addEventListener('click', function initAudioContext() {
|
129 |
+
if (!window.audioContext) {
|
130 |
+
window.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
131 |
+
}
|
132 |
+
document.removeEventListener('click', initAudioContext);
|
133 |
+
}, { once: true });
|
134 |
+
</script>
|
135 |
+
</body>
|
136 |
+
</html>
|