kaggle-environments 1.20.0__py3-none-any.whl → 1.21.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kaggle-environments might be problematic. Click here for more details.

Files changed (59) hide show
  1. kaggle_environments/__init__.py +2 -2
  2. kaggle_environments/envs/cabt/cabt.js +8 -8
  3. kaggle_environments/envs/cabt/cg/cg.dll +0 -0
  4. kaggle_environments/envs/cabt/cg/libcg.so +0 -0
  5. kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker.js +52 -28
  6. kaggle_environments/envs/{open_spiel/open_spiel.py → open_spiel_env/open_spiel_env.py} +37 -1
  7. kaggle_environments/envs/{open_spiel/test_open_spiel.py → open_spiel_env/test_open_spiel_env.py} +65 -1
  8. kaggle_environments/envs/werewolf/GAME_RULE.md +75 -0
  9. kaggle_environments/envs/werewolf/__init__.py +0 -0
  10. kaggle_environments/envs/werewolf/game/__init__.py +0 -0
  11. kaggle_environments/envs/werewolf/game/actions.py +268 -0
  12. kaggle_environments/envs/werewolf/game/base.py +115 -0
  13. kaggle_environments/envs/werewolf/game/consts.py +156 -0
  14. kaggle_environments/envs/werewolf/game/engine.py +580 -0
  15. kaggle_environments/envs/werewolf/game/night_elimination_manager.py +101 -0
  16. kaggle_environments/envs/werewolf/game/protocols/__init__.py +4 -0
  17. kaggle_environments/envs/werewolf/game/protocols/base.py +242 -0
  18. kaggle_environments/envs/werewolf/game/protocols/bid.py +248 -0
  19. kaggle_environments/envs/werewolf/game/protocols/chat.py +467 -0
  20. kaggle_environments/envs/werewolf/game/protocols/factory.py +59 -0
  21. kaggle_environments/envs/werewolf/game/protocols/vote.py +471 -0
  22. kaggle_environments/envs/werewolf/game/records.py +334 -0
  23. kaggle_environments/envs/werewolf/game/roles.py +326 -0
  24. kaggle_environments/envs/werewolf/game/states.py +214 -0
  25. kaggle_environments/envs/werewolf/game/test_actions.py +45 -0
  26. kaggle_environments/envs/werewolf/test_werewolf.py +161 -0
  27. kaggle_environments/envs/werewolf/test_werewolf_deterministic.py +211 -0
  28. kaggle_environments/envs/werewolf/werewolf.js +4377 -0
  29. kaggle_environments/envs/werewolf/werewolf.json +286 -0
  30. kaggle_environments/envs/werewolf/werewolf.py +602 -0
  31. kaggle_environments/static/player.html +19 -1
  32. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/METADATA +9 -4
  33. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/RECORD +55 -36
  34. kaggle_environments/envs/chess/chess.js +0 -4289
  35. kaggle_environments/envs/chess/chess.json +0 -60
  36. kaggle_environments/envs/chess/chess.py +0 -4241
  37. kaggle_environments/envs/chess/test_chess.py +0 -60
  38. /kaggle_environments/envs/{open_spiel → open_spiel_env}/__init__.py +0 -0
  39. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/__init__.py +0 -0
  40. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/chess.js +0 -0
  41. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/image_config.jsonl +0 -0
  42. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/openings.jsonl +0 -0
  43. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/__init__.py +0 -0
  44. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four.js +0 -0
  45. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four_proxy.py +0 -0
  46. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/__init__.py +0 -0
  47. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go.js +0 -0
  48. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go_proxy.py +0 -0
  49. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/__init__.py +0 -0
  50. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe.js +0 -0
  51. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe_proxy.py +0 -0
  52. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/__init__.py +0 -0
  53. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker_proxy.py +0 -0
  54. /kaggle_environments/envs/{open_spiel → open_spiel_env}/html_playthrough_generator.py +0 -0
  55. /kaggle_environments/envs/{open_spiel → open_spiel_env}/observation.py +0 -0
  56. /kaggle_environments/envs/{open_spiel → open_spiel_env}/proxy.py +0 -0
  57. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/WHEEL +0 -0
  58. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/entry_points.txt +0 -0
  59. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,4377 @@
1
+ function renderer(context) {
2
+ const {
3
+ environment,
4
+ step,
5
+ parent,
6
+ height = 1000,
7
+ width = 1500
8
+ } = context;
9
+
10
+ if (!parent.id) {
11
+ parent.id = 'werewolf-renderer-parent-' + Math.random().toString(36).substring(2, 9);
12
+ }
13
+ const parentId = parent.id;
14
+
15
+ const systemEntryTypeSet = new Set([
16
+ 'moderator_announcement',
17
+ 'elimination',
18
+ 'vote_request',
19
+ 'heal_request',
20
+ 'heal_result',
21
+ 'inspect_request',
22
+ 'inspect_result',
23
+ 'bidding_info',
24
+ 'bid_result',
25
+ 'day_start',
26
+ 'night_start'
27
+ ]);
28
+
29
+ if (!window.werewolfGamePlayer) {
30
+ window.werewolfGamePlayer = {
31
+ initialized: false,
32
+ allEvents: [],
33
+ displayEvents: [],
34
+ eventToKaggleStep: [],
35
+ displayStepToAllEventsIndex: [],
36
+ allEventsIndexToDisplayStep: [],
37
+ originalSteps: environment.steps,
38
+ reasoningCounter: 0,
39
+ };
40
+ const player = window.werewolfGamePlayer;
41
+
42
+ const visibleEventDataTypes = new Set([
43
+ 'ChatDataEntry',
44
+ 'DayExileVoteDataEntry',
45
+ 'WerewolfNightVoteDataEntry',
46
+ 'DoctorHealActionDataEntry',
47
+ 'SeerInspectActionDataEntry',
48
+ 'DayExileElectedDataEntry',
49
+ 'WerewolfNightEliminationDataEntry',
50
+ 'SeerInspectResultDataEntry',
51
+ 'DoctorSaveDataEntry',
52
+ 'GameEndResultsDataEntry',
53
+ 'PhaseDividerDataEntry',
54
+ 'DiscussionOrderDataEntry'
55
+ ]);
56
+
57
+ let allEventsIndex = 0;
58
+ let currentDisplayStep = 0;
59
+ const processedPhaseEvents = new Set(); // This will track events within a single phase.
60
+ (environment.info?.MODERATOR_OBSERVATION || []).forEach((stepEvents, kaggleStep) => {
61
+ (stepEvents || []).flat().forEach(dataEntry => {
62
+ const event = JSON.parse(dataEntry.json_str);
63
+ const dataType = dataEntry.data_type;
64
+ const visibleInUI = event.visible_in_ui ?? true;
65
+
66
+ console.debug(`[RAW SEEN] Kaggle Step: ${kaggleStep}`, { dataType: dataType, event: event });
67
+
68
+ if (!visibleInUI) {
69
+ return;
70
+ }
71
+
72
+ // 1. Reset our memory on key phase-changing events.
73
+ if (event.event_name === 'day_start' || event.event_name === 'night_start' || event.description?.includes('Voting phase begins')) {
74
+ processedPhaseEvents.clear();
75
+ }
76
+
77
+ // 2. Generate a unique "fingerprint" based on essential event content.
78
+ let eventFingerprint = event.description;
79
+ const eventData = event.data;
80
+
81
+ // 3. Check against the new fingerprint.
82
+ if (processedPhaseEvents.has(eventFingerprint)) {
83
+ return;
84
+ }
85
+ processedPhaseEvents.add(eventFingerprint);
86
+
87
+ const isVisibleDataType = visibleEventDataTypes.has(dataType);
88
+ const isVisibleEntryType = systemEntryTypeSet.has(event.event_name);
89
+
90
+ if (!isVisibleDataType && !isVisibleEntryType) {
91
+ return;
92
+ }
93
+
94
+ event.kaggleStep = kaggleStep;
95
+ event.dataType = dataType;
96
+ player.allEvents.push(event);
97
+ player.eventToKaggleStep.push(kaggleStep);
98
+
99
+ if (dataType !== 'PhaseDividerDataEntry') {
100
+ player.displayEvents.push(event);
101
+ player.displayStepToAllEventsIndex.push(allEventsIndex);
102
+ player.allEventsIndexToDisplayStep[allEventsIndex] = currentDisplayStep;
103
+ currentDisplayStep++;
104
+ }
105
+ allEventsIndex++;
106
+ });
107
+ });
108
+
109
+ console.debug(`[FINAL STEP LIST]`, player.displayEvents);
110
+
111
+ const newSteps = player.displayEvents.map((event) => {
112
+ return player.originalSteps[event.kaggleStep];
113
+ });
114
+
115
+ setTimeout(() => {
116
+ if (window.kaggle) {
117
+ window.kaggle.environment.steps = newSteps;
118
+ }
119
+ window.postMessage({ setSteps: newSteps }, "*");
120
+ }, 100); // A small delay to ensure player is ready
121
+ player.initialized = true;
122
+ }
123
+
124
+ // We patch the functions on the 'context' object directly.
125
+ if (context.__mainContext && !window.customPlayerControlsInjected) {
126
+ const mainContext = context.__mainContext;
127
+
128
+ if (!window.wwOriginals) {
129
+ console.debug("DEBUG: Storing original controls for the first time.");
130
+ window.wwOriginals = {
131
+ setStep: mainContext.setStep,
132
+ play: mainContext.play,
133
+ pause: mainContext.pause,
134
+ setPlaying: mainContext.setPlaying
135
+ };
136
+ }
137
+
138
+ // --- Patch setStep ---
139
+ if (mainContext.setSetStep) {
140
+ mainContext.setSetStep(() => (newStep) => {
141
+ console.debug(`DEBUG: [setStep] User manually set step to ${newStep}. Stopping audio.`);
142
+ stopAndClearAudio();
143
+ audioState.isPaused = true;
144
+ window.wwOriginals.setStep(newStep);
145
+ });
146
+ }
147
+
148
+ // --- Patch Play ---
149
+ mainContext.setPlay(() => (continuing) => {
150
+ console.debug(`DEBUG: [setPlay] Play button clicked. Continuing: ${continuing}`);
151
+
152
+ if (audioState.isAudioEnabled) {
153
+ // --- AUDIO-DRIVEN PLAYBACK ---
154
+ console.debug("DEBUG: [setPlay] Audio is ON. Using audio-driven playback.");
155
+ window.wwOriginals.setPlaying(true);
156
+ let currentDisplayStep = context.step;
157
+
158
+ if (!continuing && !audioState.isPaused && currentDisplayStep === newSteps.length - 1) {
159
+ currentDisplayStep = 0;
160
+ window.wwOriginals.setStep(0);
161
+ }
162
+
163
+ const allEventsIndex = window.werewolfGamePlayer.displayStepToAllEventsIndex[currentDisplayStep];
164
+ if (allEventsIndex === undefined) {
165
+ window.wwOriginals.setPlaying(false);
166
+ return;
167
+ }
168
+
169
+ playAudioFrom(allEventsIndex, true);
170
+
171
+ } else {
172
+ // --- TIMER-DRIVEN PLAYBACK (When audio is off) ---
173
+ console.debug("DEBUG: [setPlay] Audio is OFF. Using original Kaggle timer-based playback.");
174
+ // This call uses the original player's setTimeout logic.
175
+ window.wwOriginals.play(continuing);
176
+ }
177
+ });
178
+
179
+ // --- Patch Pause ---
180
+ mainContext.setPause(() => () => {
181
+ console.debug("DEBUG: [setPause] Pause button clicked. Stopping audio.");
182
+
183
+ // Stop the timer-based playback if it's running.
184
+ window.wwOriginals.pause();
185
+
186
+ window.wwOriginals.setPlaying(false);
187
+ audioState.isPaused = true;
188
+ if (audioState.isAudioPlaying) {
189
+ audioState.audioPlayer.pause();
190
+ }
191
+ });
192
+
193
+ mainContext.patchesApplied = true;
194
+ }
195
+
196
+ // --- THREE.js Scene Setup (Singleton Pattern) ---
197
+ if (!window.werewolfThreeJs) {
198
+ window.werewolfThreeJs = {
199
+ initialized: false,
200
+ demo: null,
201
+ };
202
+ }
203
+ const threeState = window.werewolfThreeJs;
204
+
205
+ function playAudioFrom(startIndex, isContinuous = true) {
206
+ console.debug(`DEBUG: [playAudioFrom] Called with startIndex: ${startIndex}, isContinuous: ${isContinuous}`);
207
+ if (!audioState.isAudioEnabled) {
208
+ console.error("DEBUG: [playAudioFrom] FAILED: Audio is not enabled.");
209
+ return;
210
+ }
211
+
212
+ // This tells the main Kaggle player that playback is active.
213
+ if (window.wwOriginals && window.wwOriginals.setPlaying) {
214
+ window.wwOriginals.setPlaying(true);
215
+ }
216
+ // This updates your custom audio panel's button.
217
+
218
+ stopAndClearAudio();
219
+ console.debug("DEBUG: [playAudioFrom] Audio stopped and cleared.");
220
+
221
+ if (audioState.isPaused) {
222
+ console.debug("DEBUG: [playAudioFrom] Audio state was paused.");
223
+ audioState.isPaused = false; // Un-pause regardless.
224
+
225
+ // If we're at a *new* index (e.g., user clicked slider),
226
+ // we must NOT resume. We must reload the queue.
227
+ if (startIndex !== audioState.lastStartedIndex) {
228
+ console.debug(`DEBUG: [playAudioFrom] New start index. Loading queue from: ${startIndex}`);
229
+ audioState.lastStartedIndex = startIndex;
230
+ loadQueueFrom(startIndex);
231
+ playNextInQueue(isContinuous);
232
+ return; // We are done.
233
+ }
234
+
235
+ // If we are at the *same* index, just resume from the (now empty) queue.
236
+ // The queue will be re-filled by loadQueueFrom.
237
+ console.debug("DEBUG: [playAudioFrom] Paused, resuming from same start index (or undefined).");
238
+ // Fall through to load and play.
239
+ }
240
+
241
+ audioState.isPaused = false;
242
+ audioState.lastStartedIndex = startIndex;
243
+ loadQueueFrom(startIndex);
244
+ playNextInQueue(isContinuous);
245
+ }
246
+
247
+ function loadQueueFrom(startIndex) {
248
+ console.debug(`DEBUG: [loadQueueFrom] Loading queue from index: ${startIndex}`);
249
+ if (!window.werewolfGamePlayer || !window.werewolfGamePlayer.allEvents) {
250
+ console.error("DEBUG: [loadQueueFrom] CRITICAL: allEvents not found.");
251
+ return;
252
+ }
253
+ const allEvents = window.werewolfGamePlayer.allEvents;
254
+ const eventsToPlay = allEvents.slice(startIndex);
255
+ console.debug(`DEBUG: [loadQueueFrom] Found ${eventsToPlay.length} potential events.`);
256
+
257
+ audioState.audioQueue = []; // Clear previous queue
258
+
259
+ if (eventsToPlay.length > 0) {
260
+ eventsToPlay.forEach((entry, i) => {
261
+ const allEventsIndex = startIndex + i;
262
+
263
+ let audioEventDetails = null;
264
+ const data = entry.data || {};
265
+ const event_name = entry.event_name;
266
+ const description = entry.description || '';
267
+ const day_count = entry.day;
268
+
269
+ // This logic is to identify if an event should have audio
270
+ // and what the audio content is.
271
+ switch (entry.dataType) {
272
+ case 'ChatDataEntry':
273
+ if (data.actor_id && data.actor_id !== 'moderator' && data.message) {
274
+ audioEventDetails = { message: data.message, speaker: data.actor_id };
275
+ }
276
+ break;
277
+ case 'DayExileVoteDataEntry':
278
+ if (data.actor_id && data.target_id) {
279
+ audioEventDetails = { message: `${data.actor_id} votes to exile ${data.target_id}.`, speaker: 'moderator' };
280
+ }
281
+ break;
282
+ case 'WerewolfNightVoteDataEntry':
283
+ if (data.actor_id && data.target_id) {
284
+ audioEventDetails = { message: `${data.actor_id} votes to eliminate ${data.target_id}.`, speaker: 'moderator' };
285
+ }
286
+ break;
287
+ case 'SeerInspectActionDataEntry':
288
+ if (data.actor_id && data.target_id) {
289
+ audioEventDetails = { message: `${data.actor_id} inspects ${data.target_id}.`, speaker: 'moderator' };
290
+ }
291
+ break;
292
+ case 'DoctorHealActionDataEntry':
293
+ if (data.actor_id && data.target_id) {
294
+ audioEventDetails = { message: `${data.actor_id} heals ${data.target_id}.`, speaker: 'moderator' };
295
+ }
296
+ break;
297
+ case 'DayExileElectedDataEntry':
298
+ if (data.elected_player_id && data.elected_player_role_name) {
299
+ audioEventDetails = { message: `${data.elected_player_id} was exiled by vote. Their role was a ${data.elected_player_role_name}.`, speaker: 'moderator' };
300
+ }
301
+ break;
302
+ case 'WerewolfNightEliminationDataEntry':
303
+ if (data.eliminated_player_id && data.eliminated_player_role_name) {
304
+ audioEventDetails = { message: `${data.eliminated_player_id} was eliminated. Their role was a ${data.eliminated_player_role_name}.`, speaker: 'moderator' };
305
+ }
306
+ break;
307
+ case 'DoctorSaveDataEntry':
308
+ if (data.saved_player_id) {
309
+ audioEventDetails = { message: `${data.saved_player_id} was attacked but saved by a Doctor!`, speaker: 'moderator' };
310
+ }
311
+ break;
312
+ case 'GameEndResultsDataEntry':
313
+ if (data.winner_team) {
314
+ audioEventDetails = { message: `The game is over. The ${data.winner_team} team has won!`, speaker: 'moderator' };
315
+ }
316
+ break;
317
+ case 'WerewolfNightEliminationElectedDataEntry':
318
+ if (data.elected_target_player_id) {
319
+ audioEventDetails = { message: `The werewolves have chosen to eliminate ${data.elected_target_player_id}.`, speaker: 'moderator' };
320
+ }
321
+ break;
322
+ case 'SeerInspectResultDataEntry':
323
+ if (data.role) {
324
+ audioEventDetails = { message: `${data.actor_id} saw ${data.target_id}'s role is ${data.role}.`, speaker: 'moderator'};
325
+ } else if (data.team) {
326
+ audioEventDetails = { message: `${data.actor_id} saw ${data.target_id}'s team is ${data.team}.`, speaker: 'moderator'};
327
+ }
328
+ break;
329
+ case 'DiscussionOrderDataEntry':
330
+ audioEventDetails = { message: description, speaker: 'moderator' };
331
+ }
332
+
333
+ if (!audioEventDetails && event_name === 'moderator_announcement') {
334
+ if (description.includes('discussion rule is')) {
335
+ audioEventDetails = { message: 'Discussion begins!', speaker: 'moderator' };
336
+ } else if (description.includes('Voting phase begins')) {
337
+ audioEventDetails = { message: 'Exile voting begins!', speaker: 'moderator' };
338
+ } else {
339
+ audioEventDetails = { message: entry.description, speaker: 'moderator' };
340
+ }
341
+ } else if (!audioEventDetails && event_name === 'day_start') {
342
+ audioEventDetails = { message: `Day ${day_count} begins!`, speaker: 'moderator' };
343
+ } else if (!audioEventDetails && event_name === 'night_start') {
344
+ audioEventDetails = { message: `Night ${day_count} begins!`, speaker: 'moderator' };
345
+ }
346
+
347
+ // Every event goes into the queue.
348
+ audioState.audioQueue.push({
349
+ allEventsIndex: allEventsIndex,
350
+ audioEvent: audioEventDetails, // This will be null for events without audio
351
+ });
352
+ });
353
+ }
354
+ console.debug(`DEBUG: [loadQueueFrom] Loaded ${audioState.audioQueue.length} events into queue.`);
355
+ }
356
+
357
+ function playNextInQueue(isContinuous = true) {
358
+ const currentParent = document.getElementById(parentId);
359
+ if (!currentParent) {
360
+ console.error("Werewolf renderer parent container not found in DOM, stopping playback.");
361
+ stopAndClearAudio();
362
+ return;
363
+ }
364
+
365
+ console.debug(`DEBUG: [playNextInQueue] Called. Queue length: ${audioState.audioQueue.length}. isPaused: ${audioState.isPaused}. isAudioPlaying: ${audioState.isAudioPlaying}.`);
366
+
367
+ // 1. Clear any previously highlighted element
368
+ const currentlyPlaying = currentParent.querySelector('#chat-log .now-playing');
369
+ if (currentlyPlaying) {
370
+ currentlyPlaying.classList.remove('now-playing');
371
+ }
372
+
373
+ if (audioState.isPaused || audioState.isAudioPlaying || audioState.audioQueue.length === 0 || !audioState.isAudioEnabled) {
374
+ console.warn(`DEBUG: [playNextInQueue] Exiting early. Paused: ${audioState.isPaused}, Playing: ${audioState.isAudioPlaying}, Queue: ${audioState.audioQueue.length}, Enabled: ${audioState.isAudioEnabled}`);
375
+ if (audioState.audioQueue.length === 0 && !audioState.isAudioPlaying) {
376
+ console.debug("DEBUG: [playNextInQueue] Playback finished. Setting player to 'paused' state.");
377
+ window.wwOriginals.setPlaying(false); // Stop the parent player
378
+ }
379
+ return;
380
+ }
381
+
382
+ audioState.isAudioPlaying = true;
383
+ const event = audioState.audioQueue.shift();
384
+
385
+ // This is the slider logic, it should always run
386
+ if (event.allEventsIndex !== undefined) {
387
+ const displayStep = window.werewolfGamePlayer.allEventsIndexToDisplayStep[event.allEventsIndex];
388
+ console.debug(`DEBUG: [playNextInQueue] Found displayStep: ${displayStep} for event index ${event.allEventsIndex}`);
389
+
390
+ if (displayStep !== undefined && window.wwOriginals && window.wwOriginals.setStep) {
391
+ console.debug(`DEBUG: [playNextInQueue] ### ADVANCING SLIDER TO ${displayStep} ###`);
392
+ window.wwOriginals.setStep(displayStep);
393
+
394
+ // Use a short timeout to allow the DOM to update after the step change
395
+ setTimeout(() => {
396
+ const freshParent = document.getElementById(parentId);
397
+ if (!freshParent) {
398
+ console.error(`DEBUG: Parent element not found after timeout for event index ${event.allEventsIndex}.`);
399
+ return;
400
+ }
401
+ const liToHighlight = freshParent.querySelector(`#chat-log li[data-all-events-index="${event.allEventsIndex}"]`);
402
+ console.debug(`DEBUG: [Timeout] Attempting to highlight element for index ${event.allEventsIndex}`, liToHighlight);
403
+ if (liToHighlight) {
404
+ liToHighlight.classList.add('now-playing');
405
+ console.debug(`DEBUG: [Timeout] Successfully added .now-playing to element for index ${event.allEventsIndex}`);
406
+ } else {
407
+ console.error(`DEBUG: [Timeout] FAILED to find element to highlight for index ${event.allEventsIndex}`);
408
+ }
409
+ }, 50); // A small delay to ensure the re-render completes
410
+
411
+ } else {
412
+ console.error(`DEBUG: [playNextInQueue] CRITICAL: FAILED to advance slider. displayStep: ${displayStep}, wwOriginals: ${!!window.wwOriginals}`);
413
+ }
414
+ }
415
+
416
+ let audioPath = null;
417
+ let audioKey = null;
418
+ if (event.audioEvent) {
419
+ audioKey = event.audioEvent.speaker === 'moderator' ? `moderator:${event.audioEvent.message}` : `${event.audioEvent.speaker}:${event.audioEvent.message}`;
420
+ audioPath = audioMap[audioKey];
421
+ }
422
+
423
+ if (audioPath) {
424
+ console.debug(`DEBUG: [playNextInQueue] Popped event for index: ${event.allEventsIndex}. Audio key: "${audioKey}"`);
425
+ console.debug(`DEBUG: [playNextInQueue] Playing audio: ${audioPath}`);
426
+ audioState.audioPlayer.src = audioPath;
427
+ audioState.audioPlayer.playbackRate = audioState.playbackRate;
428
+ audioState.audioPlayer.onended = () => {
429
+ console.debug(`DEBUG: [onended] Audio for index ${event.allEventsIndex} finished.`);
430
+ audioState.isAudioPlaying = false;
431
+ if (!audioState.isPaused && isContinuous) {
432
+ console.debug("DEBUG: [onended] Calling playNextInQueue recursively.");
433
+ playNextInQueue(isContinuous);
434
+ } else {
435
+ console.debug("DEBUG: [onended] Loop stopped. isPaused or !isContinuous.");
436
+ }
437
+ };
438
+ audioState.audioPlayer.onerror = () => {
439
+ console.error(`DEBUG: [onerror] Audio failed to play for key: "${audioKey}"`);
440
+ audioState.isAudioPlaying = false;
441
+ playNextInQueue(isContinuous);
442
+ };
443
+ audioState.audioPlayer.play().catch(e => {
444
+ console.error(`DEBUG: [play.catch] Audio failed to play:`, e);
445
+ audioState.isAudioPlaying = false;
446
+ playNextInQueue(isContinuous);
447
+ });
448
+ } else {
449
+ console.warn(`DEBUG: [playNextInQueue] No audio for event index: ${event.allEventsIndex}. Using setTimeout.`);
450
+ setTimeout(() => {
451
+ audioState.isAudioPlaying = false;
452
+ if (!audioState.isPaused && isContinuous) {
453
+ playNextInQueue(isContinuous);
454
+ }
455
+ }, context.speed);
456
+ }
457
+ }
458
+
459
+ function stopAndClearAudio() {
460
+ if (audioState.isAudioPlaying) {
461
+ audioState.audioPlayer.pause();
462
+ audioState.isAudioPlaying = false;
463
+ }
464
+ audioState.audioQueue = [];
465
+ audioState.currentlyPlayingIndex = -1;
466
+
467
+ const currentParent = document.getElementById(parentId);
468
+ if (currentParent) {
469
+ // Clear any "now-playing" highlights
470
+ const nowPlayingElement = currentParent.querySelector('#chat-log .now-playing');
471
+ if (nowPlayingElement) {
472
+ nowPlayingElement.classList.remove('now-playing');
473
+ }
474
+ }
475
+ }
476
+
477
+ function initThreeJs() {
478
+ if (threeState.initialized) {
479
+ if (threeState.demo && threeState.demo._parent && !parent.contains(threeState.demo._parent)) {
480
+ parent.appendChild(threeState.demo._threejs.domElement);
481
+ parent.appendChild(threeState.demo._labelRenderer.domElement);
482
+ }
483
+ return;
484
+ }
485
+
486
+ const loadAndSetup = async () => {
487
+ try {
488
+ const THREE = await import('https://cdn.jsdelivr.net/npm/three@0.118/build/three.module.js');
489
+ const { OrbitControls } = await import('https://cdn.jsdelivr.net/npm/three@0.118/examples/jsm/controls/OrbitControls.js');
490
+ const { FBXLoader } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/loaders/FBXLoader.js');
491
+ const { SkeletonUtils } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/utils/SkeletonUtils.js');
492
+ const { CSS2DRenderer, CSS2DObject } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/renderers/CSS2DRenderer.js');
493
+ const { EffectComposer } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/EffectComposer.js');
494
+ const { RenderPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/RenderPass.js');
495
+ const { UnrealBloomPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/UnrealBloomPass.js');
496
+ const { ShaderPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/ShaderPass.js');
497
+ const { FilmPass } = await import('https://cdn.jsdelivr.net/npm/three@0.118.1/examples/jsm/postprocessing/FilmPass.js');
498
+
499
+ class BasicWorldDemo {
500
+ constructor(options) {
501
+ this._Initialize(options, THREE, OrbitControls, FBXLoader, SkeletonUtils, CSS2DRenderer, CSS2DObject, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass);
502
+ }
503
+
504
+ _Initialize(options, THREE, OrbitControls, FBXLoader, SkeletonUtils, CSS2DRenderer, CSS2DObject, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass) {
505
+ this._parent = options.parent;
506
+ this._width = options.width;
507
+ this._height = options.height;
508
+
509
+ // WebGL Renderer with enhanced settings
510
+ this._threejs = new THREE.WebGLRenderer({
511
+ antialias: true,
512
+ alpha: true,
513
+ powerPreference: "high-performance"
514
+ });
515
+ this._threejs.shadowMap.enabled = true;
516
+ this._threejs.shadowMap.type = THREE.PCFSoftShadowMap;
517
+ this._threejs.shadowMap.autoUpdate = true;
518
+ this._threejs.setPixelRatio(Math.min(window.devicePixelRatio, 2));
519
+ this._threejs.setSize(this._width, this._height);
520
+ this._threejs.outputEncoding = THREE.sRGBEncoding;
521
+ this._threejs.toneMapping = THREE.ACESFilmicToneMapping;
522
+ this._threejs.toneMappingExposure = 1.2;
523
+ this._threejs.domElement.style.position = 'absolute';
524
+ this._threejs.domElement.style.top = '0';
525
+ this._threejs.domElement.style.left = '0';
526
+ this._threejs.domElement.style.zIndex = '0';
527
+ this._parent.appendChild(this._threejs.domElement);
528
+
529
+ // CSS2D Renderer
530
+ this._labelRenderer = new CSS2DRenderer();
531
+ this._labelRenderer.setSize(this._width, this._height);
532
+ this._labelRenderer.domElement.style.position = 'absolute';
533
+ this._labelRenderer.domElement.style.top = '0px';
534
+ this._labelRenderer.domElement.style.left = '0px';
535
+ this._labelRenderer.domElement.style.zIndex = '1'; // On top of 3D, behind UI
536
+ this._labelRenderer.domElement.style.pointerEvents = 'none';
537
+ this._parent.appendChild(this._labelRenderer.domElement);
538
+
539
+ const fov = 60;
540
+ const aspect = this._width / this._height;
541
+ const near = 1.0;
542
+ const far = 100000.0;
543
+ this._camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
544
+ this._camera.position.set(0, 0, 50);
545
+
546
+ this._scene = new THREE.Scene();
547
+ this._scene.fog = new THREE.FogExp2(0x2a2a4a, 0.01); // Start with day fog color
548
+
549
+ this._createSkybox(THREE);
550
+ this._createAdvancedLighting(THREE);
551
+ this._setupPostProcessing(THREE, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass);
552
+
553
+ this._controls = new OrbitControls(this._camera, this._threejs.domElement);
554
+ this._controls.target.set(0, 0, 0);
555
+ this._controls.enableKeys = false;
556
+ this._controls.update();
557
+
558
+ this._votingArcsGroup = new THREE.Group();
559
+ this._votingArcsGroup.name = 'votingArcs';
560
+ this._scene.add(this._votingArcsGroup);
561
+
562
+ this._targetRingsGroup = new THREE.Group();
563
+ this._targetRingsGroup.name = 'targetRings';
564
+ this._scene.add(this._targetRingsGroup);
565
+
566
+ this._activeVoteArcs = new Map();
567
+ this._activeTargetRings = new Map();
568
+
569
+ this._speakingAnimations = [];
570
+
571
+ this._LoadModels(THREE, FBXLoader, SkeletonUtils, CSS2DObject);
572
+ this._RAF();
573
+ }
574
+
575
+ _createSkybox(THREE) {
576
+ // Store THREE reference first
577
+ this._THREE = THREE;
578
+
579
+ const skyboxSize = 1000;
580
+ const skyboxGeo = new THREE.BoxGeometry(skyboxSize, skyboxSize, skyboxSize);
581
+
582
+ // Create materials for each face with initial day colors
583
+ this._skyboxMaterials = [];
584
+ for (let i = 0; i < 6; i++) {
585
+ const mat = new THREE.MeshBasicMaterial({
586
+ color: new THREE.Color(0x87ceeb), // Start with day color
587
+ side: THREE.BackSide
588
+ });
589
+ this._skyboxMaterials.push(mat);
590
+ }
591
+
592
+ const skybox = new THREE.Mesh(skyboxGeo, this._skyboxMaterials);
593
+ this._skybox = skybox;
594
+ this._scene.add(skybox);
595
+
596
+ // Create dynamic sky canvas for the back panel (where moon/sun appears)
597
+ const backCanvas = document.createElement('canvas');
598
+ const canvasSize = 2048;
599
+ backCanvas.width = canvasSize;
600
+ backCanvas.height = canvasSize;
601
+ this._skyCanvas = backCanvas;
602
+ this._skyContext = backCanvas.getContext('2d');
603
+
604
+ // Store celestial body properties
605
+ this._celestialBody = {
606
+ x: canvasSize / 2,
607
+ y: canvasSize / 3,
608
+ size: 250,
609
+ phase: 0.0 // Start with day (0 = day, 1 = night)
610
+ };
611
+
612
+ // Create texture from canvas
613
+ this._skyTexture = new THREE.CanvasTexture(backCanvas);
614
+ this._skyboxMaterials[4].map = this._skyTexture;
615
+
616
+ // Load moon image
617
+ const moonImage = new Image();
618
+ moonImage.crossOrigin = 'Anonymous';
619
+ moonImage.onload = () => {
620
+ this._moonImage = moonImage;
621
+ this._updateSkybox(0.0); // Start with day
622
+ };
623
+ moonImage.onerror = () => {
624
+ console.error("Failed to load moon texture for skybox.");
625
+ this._updateSkybox(0.0); // Start with day
626
+ };
627
+ moonImage.src = 'assets/moon4.png';
628
+
629
+ // Create stars for night sky
630
+ this._createStars(THREE);
631
+ }
632
+
633
+ _createStars(THREE) {
634
+ const starsGeometry = new THREE.BufferGeometry();
635
+ const starCount = 2000;
636
+ const positions = new Float32Array(starCount * 3);
637
+ const colors = new Float32Array(starCount * 3);
638
+ const sizes = new Float32Array(starCount);
639
+
640
+ for (let i = 0; i < starCount; i++) {
641
+ const i3 = i * 3;
642
+
643
+ // Random position on a large sphere
644
+ const theta = Math.random() * Math.PI * 2;
645
+ const phi = Math.acos(2 * Math.random() - 1);
646
+ const radius = 400 + Math.random() * 100;
647
+
648
+ positions[i3] = radius * Math.sin(phi) * Math.cos(theta);
649
+ positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
650
+ positions[i3 + 2] = radius * Math.cos(phi);
651
+
652
+ // Star colors (white to slightly blue/yellow)
653
+ const starColor = new THREE.Color();
654
+ const colorChoice = Math.random();
655
+ if (colorChoice < 0.3) {
656
+ starColor.setHSL(0.6, 0.1, 0.9); // Bluish
657
+ } else if (colorChoice < 0.6) {
658
+ starColor.setHSL(0.1, 0.1, 0.95); // Yellowish
659
+ } else {
660
+ starColor.setHSL(0, 0, 1); // Pure white
661
+ }
662
+ colors[i3] = starColor.r;
663
+ colors[i3 + 1] = starColor.g;
664
+ colors[i3 + 2] = starColor.b;
665
+
666
+ sizes[i] = Math.random() * 2 + 0.5;
667
+ }
668
+
669
+ starsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
670
+ starsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
671
+ starsGeometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
672
+
673
+ const starsMaterial = new THREE.ShaderMaterial({
674
+ uniforms: {
675
+ phase: { value: 0.0 } // Start with day (0 = day, 1 = night)
676
+ },
677
+ vertexShader: `
678
+ attribute float size;
679
+ attribute vec3 color;
680
+ varying vec3 vColor;
681
+ uniform float phase;
682
+
683
+ void main() {
684
+ vColor = color;
685
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
686
+ gl_PointSize = size * (300.0 / -mvPosition.z) * phase;
687
+ gl_Position = projectionMatrix * mvPosition;
688
+ }
689
+ `,
690
+ fragmentShader: `
691
+ varying vec3 vColor;
692
+ uniform float phase;
693
+
694
+ void main() {
695
+ float dist = distance(gl_PointCoord, vec2(0.5));
696
+ if (dist > 0.5) discard;
697
+
698
+ float alpha = (1.0 - dist * 2.0) * phase * 0.8;
699
+ gl_FragColor = vec4(vColor, alpha);
700
+ }
701
+ `,
702
+ transparent: true,
703
+ blending: THREE.AdditiveBlending,
704
+ depthWrite: false
705
+ });
706
+
707
+ this._stars = new THREE.Points(starsGeometry, starsMaterial);
708
+ this._starsMaterial = starsMaterial;
709
+ this._scene.add(this._stars);
710
+ }
711
+
712
+ _updateSkybox(phase) {
713
+ if (!this._skyContext || !this._skyCanvas) {
714
+ console.debug('Skybox context not ready');
715
+ return;
716
+ }
717
+
718
+ const ctx = this._skyContext;
719
+ const canvas = this._skyCanvas;
720
+ const celestial = this._celestialBody;
721
+
722
+ // Clear canvas with a transparent background
723
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
724
+
725
+ // Create gradient overlay
726
+ const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
727
+
728
+ if (phase > 0.5) {
729
+ // Night sky
730
+ const nightIntensity = (phase - 0.5) * 2;
731
+ gradient.addColorStop(0, `rgba(10, 10, 40, ${nightIntensity})`);
732
+ gradient.addColorStop(0.3, `rgba(20, 20, 60, ${nightIntensity})`);
733
+ gradient.addColorStop(1, `rgba(5, 5, 20, ${nightIntensity})`);
734
+
735
+ // Fill with gradient
736
+ ctx.fillStyle = gradient;
737
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
738
+
739
+ // Draw stars manually
740
+ ctx.fillStyle = 'white';
741
+ for (let i = 0; i < 200; i++) {
742
+ const x = Math.random() * canvas.width;
743
+ const y = Math.random() * canvas.height;
744
+ const size = Math.random() * 2;
745
+ ctx.globalAlpha = nightIntensity * (0.3 + Math.random() * 0.7);
746
+ ctx.beginPath();
747
+ ctx.arc(x, y, size, 0, Math.PI * 2);
748
+ ctx.fill();
749
+ }
750
+
751
+ // Draw moon
752
+ ctx.globalAlpha = nightIntensity;
753
+ if (this._moonImage) {
754
+ const moonSize = celestial.size;
755
+ const moonX = celestial.x;
756
+ const moonY = celestial.y;
757
+ ctx.drawImage(this._moonImage,
758
+ moonX - moonSize/2,
759
+ moonY - moonSize/2,
760
+ moonSize,
761
+ moonSize
762
+ );
763
+ } else {
764
+ // Fallback: draw procedural moon
765
+ ctx.fillStyle = '#f0f0e0';
766
+ ctx.shadowBlur = 50;
767
+ ctx.shadowColor = '#f0f0e0';
768
+ ctx.beginPath();
769
+ ctx.arc(celestial.x, celestial.y, celestial.size/2, 0, Math.PI * 2);
770
+ ctx.fill();
771
+ ctx.shadowBlur = 0;
772
+ }
773
+ } else {
774
+ // Day sky
775
+ const dayIntensity = 1 - phase * 2;
776
+ gradient.addColorStop(0, `rgba(135, 206, 250, ${dayIntensity})`);
777
+ gradient.addColorStop(0.5, `rgba(135, 206, 235, ${dayIntensity})`);
778
+ gradient.addColorStop(1, `rgba(255, 255, 200, ${dayIntensity * 0.5})`);
779
+
780
+ // Fill with gradient
781
+ ctx.fillStyle = gradient;
782
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
783
+
784
+ // Draw sun
785
+ ctx.globalAlpha = dayIntensity;
786
+
787
+ // Sun glow
788
+ const glowGradient = ctx.createRadialGradient(
789
+ celestial.x, celestial.y, 0,
790
+ celestial.x, celestial.y, celestial.size * 1.5
791
+ );
792
+ glowGradient.addColorStop(0, 'rgba(255, 255, 200, 0.8)');
793
+ glowGradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.4)');
794
+ glowGradient.addColorStop(1, 'rgba(255, 200, 50, 0)');
795
+
796
+ ctx.fillStyle = glowGradient;
797
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
798
+
799
+ // Sun core
800
+ ctx.fillStyle = '#ffff99';
801
+ ctx.shadowBlur = 80;
802
+ ctx.shadowColor = '#ffcc00';
803
+ ctx.beginPath();
804
+ ctx.arc(celestial.x, celestial.y, celestial.size/2, 0, Math.PI * 2);
805
+ ctx.fill();
806
+ ctx.shadowBlur = 0;
807
+
808
+ // Sun rays
809
+ ctx.strokeStyle = `rgba(255, 220, 100, ${dayIntensity * 0.5})`;
810
+ ctx.lineWidth = 3;
811
+ for (let i = 0; i < 12; i++) {
812
+ const angle = (i / 12) * Math.PI * 2;
813
+ const innerRadius = celestial.size * 0.6;
814
+ const outerRadius = celestial.size * 1.2;
815
+ ctx.beginPath();
816
+ ctx.moveTo(
817
+ celestial.x + Math.cos(angle) * innerRadius,
818
+ celestial.y + Math.sin(angle) * innerRadius
819
+ );
820
+ ctx.lineTo(
821
+ celestial.x + Math.cos(angle) * outerRadius,
822
+ celestial.y + Math.sin(angle) * outerRadius
823
+ );
824
+ ctx.stroke();
825
+ }
826
+ }
827
+
828
+ // Update texture
829
+ if (this._skyTexture) {
830
+ this._skyTexture.needsUpdate = true;
831
+ }
832
+
833
+ // Update skybox colors for all faces with more dramatic changes
834
+ if (this._skyboxMaterials && this._THREE) {
835
+ const THREE = this._THREE;
836
+ const nightColor = new THREE.Color(0x000011); // Very dark blue
837
+ const dayColor = new THREE.Color(0x87ceeb); // Sky blue
838
+ const currentColor = new THREE.Color();
839
+ currentColor.copy(dayColor).lerp(nightColor, phase);
840
+
841
+ this._skyboxMaterials.forEach((mat, index) => {
842
+ if (index !== 4) { // Skip the back panel with moon/sun
843
+ mat.color.copy(currentColor);
844
+ mat.needsUpdate = true;
845
+ }
846
+ });
847
+
848
+ // Force update the canvas texture
849
+ if (this._skyTexture) {
850
+ this._skyTexture.needsUpdate = true;
851
+ }
852
+ }
853
+
854
+ // Store current phase
855
+ if (celestial) {
856
+ celestial.phase = phase;
857
+ }
858
+ }
859
+
860
+ _createAdvancedLighting(THREE) {
861
+ // Enhanced ambient lighting with color variation
862
+ const ambientLight = new THREE.AmbientLight(0x404080, 0.4);
863
+ ambientLight.name = 'ambientLight';
864
+ this._scene.add(ambientLight);
865
+
866
+ // Main directional light (moon/sun)
867
+ const mainLight = new THREE.DirectionalLight(0xffffff, 1.8);
868
+ mainLight.position.set(30, 50, 20);
869
+ mainLight.castShadow = true;
870
+ mainLight.shadow.mapSize.width = 2048;
871
+ mainLight.shadow.mapSize.height = 2048;
872
+ mainLight.shadow.camera.near = 0.5;
873
+ mainLight.shadow.camera.far = 100;
874
+ mainLight.shadow.camera.left = -50;
875
+ mainLight.shadow.camera.right = 50;
876
+ mainLight.shadow.camera.top = 50;
877
+ mainLight.shadow.camera.bottom = -50;
878
+ mainLight.shadow.bias = -0.001;
879
+ mainLight.shadow.normalBias = 0.02;
880
+ this._scene.add(mainLight);
881
+
882
+ // Rim light for dramatic effect
883
+ const rimLight = new THREE.DirectionalLight(0x8080ff, 0.8);
884
+ rimLight.position.set(-20, 10, -30);
885
+ this._scene.add(rimLight);
886
+
887
+ // Atmospheric hemisphere light
888
+ const hemiLight = new THREE.HemisphereLight(0x87ceeb, 0x1e1e3f, 0.6);
889
+ this._scene.add(hemiLight);
890
+
891
+ // Ground fill light
892
+ const fillLight = new THREE.DirectionalLight(0x404080, 0.3);
893
+ fillLight.position.set(0, -1, 0);
894
+ this._scene.add(fillLight);
895
+
896
+ // Store references for phase updates
897
+ this._mainLight = mainLight;
898
+ this._rimLight = rimLight;
899
+ this._hemiLight = hemiLight;
900
+ this._fillLight = fillLight;
901
+
902
+ // Create a spotlight for night actions
903
+ const spotLight = new THREE.SpotLight(0xffffff, 5.0, 50, Math.PI / 4, 0.5, 2);
904
+ spotLight.position.set(0, 25, 0);
905
+ spotLight.castShadow = true;
906
+ spotLight.visible = false;
907
+ this._scene.add(spotLight);
908
+ this._spotLight = spotLight;
909
+ this._scene.add(spotLight.target);
910
+ }
911
+
912
+ _createMysticalCircles(THREE, radius) {
913
+ // Create multiple concentric circles with mystical patterns
914
+ for (let i = 0; i < 3; i++) {
915
+ const circleRadius = radius - (i * 2) - 1;
916
+ const circleGeometry = new THREE.RingGeometry(circleRadius - 0.1, circleRadius + 0.1, 64);
917
+ const circleMaterial = new THREE.MeshStandardMaterial({
918
+ color: new THREE.Color().setHSL(0.6 + i * 0.1, 0.8, 0.3 + i * 0.1),
919
+ emissive: new THREE.Color().setHSL(0.6 + i * 0.1, 0.5, 0.1),
920
+ emissiveIntensity: 0.2,
921
+ transparent: true,
922
+ opacity: 0.6 - i * 0.1,
923
+ side: THREE.DoubleSide
924
+ });
925
+
926
+ const circle = new THREE.Mesh(circleGeometry, circleMaterial);
927
+ circle.rotation.x = -Math.PI / 2;
928
+ circle.position.y = 0.01 + i * 0.001;
929
+ this._scene.add(circle);
930
+ }
931
+
932
+ // Add runic symbols around the outer circle
933
+ // this._createRunicSymbols(THREE, radius);
934
+ }
935
+
936
+ _createRunicSymbols(THREE, radius) {
937
+ const symbolCount = 8;
938
+ const symbolGeometry = new THREE.PlaneGeometry(1, 1);
939
+
940
+ for (let i = 0; i < symbolCount; i++) {
941
+ const angle = (i / symbolCount) * Math.PI * 2;
942
+ const x = (radius + 3) * Math.sin(angle);
943
+ const z = (radius + 3) * Math.cos(angle);
944
+
945
+ // Create a simple runic-like pattern using canvas
946
+ const canvas = document.createElement('canvas');
947
+ canvas.width = 64;
948
+ canvas.height = 64;
949
+ const ctx = canvas.getContext('2d');
950
+
951
+ ctx.fillStyle = 'rgba(100, 150, 255, 0.8)';
952
+ ctx.font = '40px serif';
953
+ ctx.textAlign = 'center';
954
+ ctx.fillText(['ᚠ', 'ᚡ', 'ᚢ', 'ᚣ', 'ᚤ', 'ᚥ', 'ᚦ', 'ᚧ'][i], 32, 45);
955
+
956
+ const symbolMaterial = new THREE.MeshBasicMaterial({
957
+ map: new THREE.CanvasTexture(canvas),
958
+ transparent: true,
959
+ alphaTest: 0.1
960
+ });
961
+
962
+ const symbol = new THREE.Mesh(symbolGeometry, symbolMaterial);
963
+ symbol.position.set(x, 0.15, z);
964
+ symbol.rotation.x = -Math.PI / 2;
965
+ this._scene.add(symbol);
966
+ }
967
+ }
968
+
969
+ _createParticleSystem(THREE) {
970
+ // Create floating mystical particles
971
+ const particleCount = 150;
972
+ const particles = new THREE.BufferGeometry();
973
+ const positions = new Float32Array(particleCount * 3);
974
+ const colors = new Float32Array(particleCount * 3);
975
+ const sizes = new Float32Array(particleCount);
976
+
977
+ for (let i = 0; i < particleCount; i++) {
978
+ const i3 = i * 3;
979
+
980
+ // Random position within a larger area
981
+ positions[i3] = (Math.random() - 0.5) * 80;
982
+ positions[i3 + 1] = Math.random() * 30 + 5;
983
+ positions[i3 + 2] = (Math.random() - 0.5) * 80;
984
+
985
+ // Mystical colors (blues, purples, greens)
986
+ const hue = Math.random() * 0.3 + 0.5; // 0.5-0.8 range
987
+ const color = new THREE.Color().setHSL(hue, 0.8, 0.6);
988
+ colors[i3] = color.r;
989
+ colors[i3 + 1] = color.g;
990
+ colors[i3 + 2] = color.b;
991
+
992
+ sizes[i] = Math.random() * 2 + 0.5;
993
+ }
994
+
995
+ particles.setAttribute('position', new THREE.BufferAttribute(positions, 3));
996
+ particles.setAttribute('color', new THREE.BufferAttribute(colors, 3));
997
+ particles.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
998
+
999
+ const particleMaterial = new THREE.ShaderMaterial({
1000
+ uniforms: {
1001
+ time: { value: 0 }
1002
+ },
1003
+ vertexShader: `
1004
+ attribute float size;
1005
+ attribute vec3 color;
1006
+ varying vec3 vColor;
1007
+ uniform float time;
1008
+
1009
+ void main() {
1010
+ vColor = color;
1011
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1012
+ gl_PointSize = size * (300.0 / -mvPosition.z) * (1.0 + sin(time * 2.0 + position.x * 0.1) * 0.3);
1013
+ gl_Position = projectionMatrix * mvPosition;
1014
+ }
1015
+ `,
1016
+ fragmentShader: `
1017
+ varying vec3 vColor;
1018
+
1019
+ void main() {
1020
+ float dist = distance(gl_PointCoord, vec2(0.5));
1021
+ if (dist > 0.5) discard;
1022
+
1023
+ float alpha = 1.0 - (dist * 2.0);
1024
+ alpha *= alpha; // Softer edges
1025
+
1026
+ gl_FragColor = vec4(vColor, alpha * 0.6);
1027
+ }
1028
+ `,
1029
+ transparent: true,
1030
+ blending: THREE.AdditiveBlending,
1031
+ depthWrite: false
1032
+ });
1033
+
1034
+ this._particles = new THREE.Points(particles, particleMaterial);
1035
+ this._scene.add(this._particles);
1036
+ this._particleMaterial = particleMaterial;
1037
+ }
1038
+
1039
+ _setupPostProcessing(THREE, EffectComposer, RenderPass, UnrealBloomPass, ShaderPass, FilmPass) {
1040
+ // Create effect composer
1041
+ this._composer = new EffectComposer(this._threejs);
1042
+
1043
+ // Render pass
1044
+ const renderPass = new RenderPass(this._scene, this._camera);
1045
+ this._composer.addPass(renderPass);
1046
+
1047
+ // Bloom pass for glowing effects - balanced for day
1048
+ const bloomPass = new UnrealBloomPass(
1049
+ new THREE.Vector2(this._width, this._height),
1050
+ 0.15, // strength - moderate for day
1051
+ 0.333, // radius - good coverage for day
1052
+ 0.4 // threshold - balanced to allow some bloom
1053
+ );
1054
+ this._composer.addPass(bloomPass);
1055
+
1056
+ // Film grain for atmosphere
1057
+ const filmPass = new FilmPass(
1058
+ 0.15, // noise intensity
1059
+ 0.1, // scanline intensity
1060
+ 0, // scanline count
1061
+ false // grayscale
1062
+ );
1063
+ this._composer.addPass(filmPass);
1064
+
1065
+ // Custom atmospheric shader
1066
+ const atmosphereShader = {
1067
+ uniforms: {
1068
+ 'tDiffuse': { value: null },
1069
+ 'time': { value: 0.0 },
1070
+ 'phase': { value: 0.0 } // 0 = day, 1 = night
1071
+ },
1072
+ vertexShader: `
1073
+ varying vec2 vUv;
1074
+ void main() {
1075
+ vUv = uv;
1076
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1077
+ }
1078
+ `,
1079
+ fragmentShader: `
1080
+ uniform sampler2D tDiffuse;
1081
+ uniform float time;
1082
+ uniform float phase;
1083
+ varying vec2 vUv;
1084
+
1085
+ void main() {
1086
+ vec4 color = texture2D(tDiffuse, vUv);
1087
+
1088
+ // Add subtle color grading based on phase
1089
+ if (phase > 0.5) {
1090
+ // Night - add blue tint and increase contrast
1091
+ color.rgb = mix(color.rgb, color.rgb * vec3(0.8, 0.9, 1.2), 0.3);
1092
+ color.rgb = pow(color.rgb, vec3(1.1));
1093
+ } else {
1094
+ // Day - add warm tint
1095
+ color.rgb = mix(color.rgb, color.rgb * vec3(1.1, 1.05, 0.95), 0.2);
1096
+ }
1097
+
1098
+ // Add subtle vignette
1099
+ vec2 center = vec2(0.5, 0.5);
1100
+ float dist = distance(vUv, center);
1101
+ float vignette = 1.0 - smoothstep(0.3, 0.8, dist);
1102
+ color.rgb *= mix(0.7, 1.0, vignette);
1103
+
1104
+ gl_FragColor = color;
1105
+ }
1106
+ `
1107
+ };
1108
+
1109
+ this._atmospherePass = new ShaderPass(atmosphereShader);
1110
+ this._composer.addPass(this._atmospherePass);
1111
+
1112
+ // Store references
1113
+ this._bloomPass = bloomPass;
1114
+ this._filmPass = filmPass;
1115
+ }
1116
+
1117
+ _LoadModels(THREE, FBXLoader, SkeletonUtils, CSS2DObject) {
1118
+ this._playerObjects = new Map();
1119
+ this._playerGroup = new THREE.Group();
1120
+ this._playerGroup.name = 'playerGroup';
1121
+
1122
+ // Create enhanced ground circle with better materials
1123
+ const radius = 15;
1124
+ const groundGeometry = new THREE.CircleGeometry(radius + 5, 64);
1125
+ const groundMaterial = new THREE.MeshStandardMaterial({
1126
+ color: 0x1a1a2a,
1127
+ roughness: 0.9,
1128
+ metalness: 0.1,
1129
+ normalScale: new THREE.Vector2(0.5, 0.5),
1130
+ transparent: true,
1131
+ opacity: 0.95
1132
+ });
1133
+
1134
+ // Add a subtle normal map pattern
1135
+ const canvas = document.createElement('canvas');
1136
+ canvas.width = 512;
1137
+ canvas.height = 512;
1138
+ const ctx = canvas.getContext('2d');
1139
+ const imageData = ctx.createImageData(512, 512);
1140
+ for (let i = 0; i < imageData.data.length; i += 4) {
1141
+ const noise = Math.random() * 0.1 + 0.5;
1142
+ imageData.data[i] = Math.floor(noise * 255); // R
1143
+ imageData.data[i + 1] = Math.floor(noise * 255); // G
1144
+ imageData.data[i + 2] = 255; // B
1145
+ imageData.data[i + 3] = 255; // A
1146
+ }
1147
+ ctx.putImageData(imageData, 0, 0);
1148
+ groundMaterial.normalMap = new THREE.CanvasTexture(canvas);
1149
+
1150
+ const ground = new THREE.Mesh(groundGeometry, groundMaterial);
1151
+ ground.rotation.x = -Math.PI / 2;
1152
+ ground.position.y = -0.1;
1153
+ ground.receiveShadow = true;
1154
+ this._scene.add(ground);
1155
+
1156
+ // Add mystical circle patterns
1157
+ this._createMysticalCircles(THREE, radius);
1158
+
1159
+ this._scene.add(this._playerGroup);
1160
+
1161
+ // Store references for later use
1162
+ this._THREE = THREE;
1163
+ this._CSS2DObject = CSS2DObject;
1164
+
1165
+ // Create particle system for atmosphere
1166
+ this._createParticleSystem(THREE);
1167
+
1168
+ // Frame the empty group initially with better camera positioning
1169
+ this._camera.position.set(25, 30, 35);
1170
+ this._controls.target.set(0, 8, 0);
1171
+ this._controls.enableDamping = true;
1172
+ this._controls.dampingFactor = 0.05;
1173
+ this._controls.minDistance = 20;
1174
+ this._controls.maxDistance = 80;
1175
+ this._controls.maxPolarAngle = Math.PI * 0.75;
1176
+ this._controls.update();
1177
+ }
1178
+
1179
+ focusOnPlayer(playerName, leftPanelWidth = 0, rightPanelWidth = 0) {
1180
+ if (!this._playerGroup || this._playerGroup.children.length === 0 || !this._THREE || !this._playerObjects) {
1181
+ return;
1182
+ }
1183
+ const player = this._playerObjects.get(playerName);
1184
+ if (!player) return;
1185
+
1186
+ // --- 1. Calculate the required camera distance ---
1187
+
1188
+ // First, determine the real viewport size, excluding the UI panels
1189
+ const effectiveWidth = this._width - leftPanelWidth - rightPanelWidth;
1190
+ const effectiveHeight = this._height;
1191
+
1192
+ // Get the bounding box of the entire group of players
1193
+ const viewBox = new this._THREE.Box3().setFromObject(this._playerGroup);
1194
+ const viewSize = viewBox.getSize(new this._THREE.Vector3());
1195
+ const viewCenter = viewBox.getCenter(new this._THREE.Vector3());
1196
+
1197
+ // Calculate the camera's field of view in radians
1198
+ const fov = this._camera.fov * (Math.PI / 180);
1199
+ const aspect = effectiveWidth / effectiveHeight;
1200
+
1201
+ // Derive the horizontal FoV from the vertical FoV and the new aspect ratio
1202
+ const horizontalFov = 2 * Math.atan(Math.tan(fov / 2) * aspect);
1203
+
1204
+ // Calculate the distance needed to fit the content vertically and horizontally
1205
+ const distV = (viewSize.y / 2) / Math.tan(fov / 2);
1206
+ const distH = (viewSize.x / 2) / Math.tan(horizontalFov / 2);
1207
+
1208
+ // The required distance is the larger of the two, plus some padding
1209
+ let distance = Math.max(distV, distH) * 1.05;
1210
+
1211
+ // --- 2. Position the camera using the calculated distance ---
1212
+
1213
+ const playerPosition = player.container.position.clone();
1214
+ const direction = playerPosition.clone().normalize();
1215
+
1216
+ // We preserve the angle you liked by scaling the position based on the new distance.
1217
+ // The camera is positioned on the line extending from the center through the player.
1218
+ const endPos = playerPosition.clone().add(direction.multiplyScalar(distance * 0.6));
1219
+ endPos.y = playerPosition.y + distance * 0.5; // Elevate based on distance
1220
+
1221
+ // The target remains the center of the action
1222
+ const endTarget = viewCenter;
1223
+
1224
+ // --- 3. Animate the transition ---
1225
+
1226
+ this._cameraAnimation = {
1227
+ startTime: performance.now(),
1228
+ duration: 1200,
1229
+ startPos: this._camera.position.clone(),
1230
+ endPos: endPos,
1231
+ startTarget: this._controls.target.clone(),
1232
+ endTarget: endTarget,
1233
+ ease: t => 1 - Math.pow(1 - t, 3)
1234
+ };
1235
+ }
1236
+
1237
+ resetCameraView() {
1238
+ if (!this._playerGroup || this._playerGroup.children.length === 0 || !this._THREE) {
1239
+ return; // Can't frame an empty group
1240
+ }
1241
+
1242
+ // Calculate the bounding box that contains all players
1243
+ const box = new this._THREE.Box3().setFromObject(this._playerGroup);
1244
+ const size = box.getSize(new this._THREE.Vector3());
1245
+ const center = box.getCenter(new this._THREE.Vector3());
1246
+
1247
+ // Determine the maximum dimension of the box
1248
+ const maxDim = Math.max(size.x, size.y, size.z);
1249
+ const fov = this._camera.fov * (Math.PI / 180);
1250
+
1251
+ // Calculate the distance the camera needs to be to fit the box
1252
+ let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
1253
+
1254
+ // Add some padding so the players aren't right at the edge of the screen
1255
+ // cameraZ *= 1.4;
1256
+ cameraZ *= 1.1;
1257
+
1258
+ // Set a nice isometric-style camera position
1259
+ const endPos = new this._THREE.Vector3(
1260
+ center.x,
1261
+ center.y + cameraZ / 2, // Elevate the camera
1262
+ center.z + cameraZ // Pull it back
1263
+ );
1264
+
1265
+ // The target is the center of the player group
1266
+ const endTarget = center;
1267
+
1268
+ // Use the same animation system as focusOnPlayer
1269
+ this._cameraAnimation = {
1270
+ startTime: performance.now(),
1271
+ duration: 1200,
1272
+ startPos: this._camera.position.clone(),
1273
+ endPos: endPos,
1274
+ startTarget: this._controls.target.clone(),
1275
+ endTarget: endTarget,
1276
+ ease: t => 1 - Math.pow(1 - t, 3)
1277
+ };
1278
+ }
1279
+
1280
+ updatePlayerActive(playerName) {
1281
+ const player = this._playerObjects.get(playerName);
1282
+ if (!player) return;
1283
+ const { orb, orbLight, body, head, shoulders, glow, pedestal, container } = player;
1284
+
1285
+ orb.material.emissiveIntensity = 1.;
1286
+ orbLight.intensity = 1.;
1287
+ glow.material.emissiveIntensity = 0.5;
1288
+ // Slight scale up animation
1289
+ container.scale.setScalar(1.1);
1290
+ pedestal.material.emissiveIntensity = 0.3;
1291
+ }
1292
+
1293
+ updatePlayerStatus(playerName, status, threatLevel = 0, is_active = false) {
1294
+ const player = this._playerObjects.get(playerName);
1295
+ if (!player) return;
1296
+
1297
+ const { orb, orbLight, body, head, shoulders, glow, pedestal, container } = player;
1298
+
1299
+ orb.material.color.setHex(0x00ff00);
1300
+ orb.material.emissive.setHex(0x00ff00);
1301
+ orb.material.emissiveIntensity = 0.8;
1302
+ orb.material.opacity = 0.9;
1303
+ orb.visible = true;
1304
+ orbLight.color.setHex(0x00ff00);
1305
+ orbLight.intensity = 0.8;
1306
+ orbLight.visible = true;
1307
+ body.material.color.setHex(0x4466ff);
1308
+ body.material.emissive.setHex(0x111166);
1309
+ body.material.emissiveIntensity = 0.2;
1310
+ shoulders.material.color.setHex(0x4466ff);
1311
+ shoulders.material.emissive.setHex(0x111166);
1312
+ shoulders.material.emissiveIntensity = 0.2;
1313
+ head.material.color.setHex(0xfdbcb4);
1314
+ head.material.emissive.setHex(0x442211);
1315
+ head.material.emissiveIntensity = 0.1;
1316
+ glow.material.color.setHex(0x00ff00);
1317
+ glow.material.emissive.setHex(0x00ff00);
1318
+ glow.material.emissiveIntensity = 0.3;
1319
+ glow.visible = true;
1320
+ pedestal.material.emissive.setHex(0x111122);
1321
+ pedestal.material.emissiveIntensity = 0.1;
1322
+ container.scale.setScalar(1.0);
1323
+ container.position.y = 0;
1324
+ container.rotation.x = 0;
1325
+ if (player.nameplate && player.nameplate.element) {
1326
+ player.nameplate.element.style.transition = 'opacity 0.5s ease-in';
1327
+ player.nameplate.element.style.opacity = '1.0';
1328
+ }
1329
+ player.isAlive = true;
1330
+
1331
+ switch(status) {
1332
+ case 'dead':
1333
+ orb.visible = false;
1334
+ orbLight.visible = false;
1335
+ glow.visible = false;
1336
+ body.material.color.setHex(0x444444);
1337
+ body.material.emissive.setHex(0x000000);
1338
+ shoulders.material.color.setHex(0x444444);
1339
+ shoulders.material.emissive.setHex(0x000000);
1340
+ head.material.color.setHex(0x666666);
1341
+ head.material.emissive.setHex(0x000000);
1342
+ pedestal.material.emissive.setHex(0x000000);
1343
+ // Sink into ground
1344
+ container.position.y = -1.5;
1345
+ // Tilt slightly
1346
+ container.rotation.x = 0.2;
1347
+ // Fade out nameplate
1348
+ if (player.nameplate && player.nameplate.element) {
1349
+ player.nameplate.element.style.transition = 'opacity 2s ease-out';
1350
+ player.nameplate.element.style.opacity = '0.2';
1351
+ }
1352
+ player.isAlive = false;
1353
+ break;
1354
+ case 'werewolf':
1355
+ body.material.color.setHex(0x880000);
1356
+ body.material.emissive.setHex(0x440000);
1357
+ body.material.emissiveIntensity = 0.3;
1358
+ shoulders.material.color.setHex(0x880000);
1359
+ shoulders.material.emissive.setHex(0x440000);
1360
+ shoulders.material.emissiveIntensity = 0.3;
1361
+ glow.material.color.setHex(0xff0000);
1362
+ glow.material.emissive.setHex(0xff0000);
1363
+ glow.material.emissiveIntensity = 0.4;
1364
+ glow.visible = true;
1365
+ pedestal.material.emissive.setHex(0x440000);
1366
+ pedestal.material.emissiveIntensity = 0.2;
1367
+ break;
1368
+ case 'doctor':
1369
+ body.material.color.setHex(0x008800);
1370
+ body.material.emissive.setHex(0x004400);
1371
+ body.material.emissiveIntensity = 0.3;
1372
+ shoulders.material.color.setHex(0x008800);
1373
+ shoulders.material.emissive.setHex(0x004400);
1374
+ shoulders.material.emissiveIntensity = 0.3;
1375
+ glow.material.color.setHex(0x00ff00);
1376
+ glow.material.emissive.setHex(0x00ff00);
1377
+ glow.material.emissiveIntensity = 0.4;
1378
+ glow.visible = true;
1379
+ pedestal.material.emissive.setHex(0x004400);
1380
+ pedestal.material.emissiveIntensity = 0.2;
1381
+ break;
1382
+ case 'seer':
1383
+ body.material.color.setHex(0x4B0082);
1384
+ body.material.emissive.setHex(0x3A005A);
1385
+ body.material.emissiveIntensity = 0.3;
1386
+ shoulders.material.color.setHex(0x4B0082);
1387
+ shoulders.material.emissive.setHex(0x3A005A);
1388
+ shoulders.material.emissiveIntensity = 0.3;
1389
+ glow.material.color.setHex(0x9932CC);
1390
+ glow.material.emissive.setHex(0x9932CC);
1391
+ glow.material.emissiveIntensity = 0.4;
1392
+ glow.visible = true;
1393
+ pedestal.material.emissive.setHex(0x3A005A);
1394
+ pedestal.material.emissiveIntensity = 0.2;
1395
+ break;
1396
+ default:
1397
+ // This is now covered by the reset block at the top of the function.
1398
+ break;
1399
+ }
1400
+
1401
+ if (threatLevel >= 1.0) { // DANGER
1402
+ orb.material.color.setHex(0xff0000); // Red
1403
+ orb.material.emissive.setHex(0xff0000);
1404
+ orb.material.emissiveIntensity = 1.0;
1405
+ orb.material.opacity = 0.9;
1406
+ orbLight.color.setHex(0xff0000);
1407
+ orbLight.intensity = 1.2;
1408
+ glow.material.color.setHex(0xff0000);
1409
+ glow.material.emissive.setHex(0xff0000);
1410
+ glow.material.emissiveIntensity = 0.3;
1411
+ } else if (threatLevel >= 0.5) { // UNEASY
1412
+ orb.material.color.setHex(0xffff00); // Yellow
1413
+ orb.material.emissive.setHex(0xffff00);
1414
+ orb.material.emissiveIntensity = 1.0;
1415
+ orb.material.opacity = 0.9;
1416
+ orbLight.color.setHex(0xffff00);
1417
+ orbLight.intensity = 1.2;
1418
+ glow.material.color.setHex(0xffff00);
1419
+ glow.material.emissive.setHex(0xffff00);
1420
+ glow.material.emissiveIntensity = 0.3;
1421
+ } else { // SAFE
1422
+ // orb.material.color.setHex(0x00ff00); // Green
1423
+ orb.material.color.setHex(0x00ff00);
1424
+ orb.material.emissive.setHex(0x00ff00);
1425
+ orb.material.emissiveIntensity = 1.0;
1426
+ orb.material.opacity = 0.9;
1427
+ orbLight.color.setHex(0x00ff00);
1428
+ orbLight.intensity = 1.2;
1429
+ glow.material.color.setHex(0x00ff00);
1430
+ glow.material.emissive.setHex(0x00ff00);
1431
+ glow.material.emissiveIntensity = 0.3;
1432
+ }
1433
+ }
1434
+
1435
+ triggerSpeakingAnimation(playerName) {
1436
+ const player = this._playerObjects.get(playerName);
1437
+ if (!player || !player.isAlive) return;
1438
+
1439
+ const wave = this._createSoundWave(this._THREE);
1440
+ player.container.add(wave);
1441
+
1442
+ // Add the wave to our animation manager array
1443
+ this._speakingAnimations.push({
1444
+ mesh: wave,
1445
+ startTime: performance.now(),
1446
+ duration: 1800, // Animation duration in milliseconds
1447
+ });
1448
+ }
1449
+
1450
+ _createSoundWave(THREE) {
1451
+ const waveGeometry = new THREE.RingGeometry(0.5, 0.7, 32);
1452
+ const waveMaterial = new THREE.MeshBasicMaterial({
1453
+ color: 0xffffff,
1454
+ transparent: true,
1455
+ opacity: 0.8,
1456
+ side: THREE.DoubleSide,
1457
+ });
1458
+ const wave = new THREE.Mesh(waveGeometry, waveMaterial);
1459
+
1460
+ // Position the wave horizontally at the player's feet
1461
+ wave.rotation.x = -Math.PI / 2;
1462
+ wave.position.y = 0.25; // Slightly above the pedestal
1463
+ return wave;
1464
+ }
1465
+
1466
+ _createVoteParticleTrail(voterName, targetName, color = 0x00ffff) {
1467
+ const voter = this._playerObjects.get(voterName);
1468
+ const target = this._playerObjects.get(targetName);
1469
+ if (!voter || !target) return;
1470
+
1471
+ const startPos = voter.container.position.clone();
1472
+ startPos.y += 1.5; // Start above the voter's head
1473
+ const endPos = target.container.position.clone();
1474
+ endPos.y += 1.5; // End above the target's head
1475
+
1476
+ const midPos = new this._THREE.Vector3().addVectors(startPos, endPos).multiplyScalar(0.5);
1477
+ const dist = startPos.distanceTo(endPos);
1478
+ midPos.y += dist * 0.3; // Arc height
1479
+
1480
+ const curve = new this._THREE.CatmullRomCurve3([startPos, midPos, endPos]);
1481
+ const particleCount = 50;
1482
+ const particleGeometry = new this._THREE.BufferGeometry();
1483
+ const positions = new Float32Array(particleCount * 3);
1484
+ particleGeometry.setAttribute('position', new this._THREE.BufferAttribute(positions, 3));
1485
+
1486
+ const particleMaterial = new this._THREE.PointsMaterial({
1487
+ color: color,
1488
+ size: 0.3,
1489
+ transparent: true,
1490
+ opacity: 0.8,
1491
+ blending: this._THREE.AdditiveBlending,
1492
+ sizeAttenuation: true,
1493
+ });
1494
+
1495
+ const particles = new this._THREE.Points(particleGeometry, particleMaterial);
1496
+ this._votingArcsGroup.add(particles);
1497
+
1498
+ const trail = {
1499
+ particles,
1500
+ curve,
1501
+ target: targetName,
1502
+ startTime: Date.now(),
1503
+ update: () => {
1504
+ const elapsedTime = (Date.now() - trail.startTime) / 1000;
1505
+ const positions = trail.particles.geometry.attributes.position.array;
1506
+ for (let i = 0; i < particleCount; i++) {
1507
+ const t = (elapsedTime * 0.2 + (i / particleCount)) % 1;
1508
+ const pos = trail.curve.getPointAt(t);
1509
+ positions[i * 3] = pos.x;
1510
+ positions[i * 3 + 1] = pos.y;
1511
+ positions[i * 3 + 2] = pos.z;
1512
+ }
1513
+ trail.particles.geometry.attributes.position.needsUpdate = true;
1514
+ }
1515
+ };
1516
+ this._activeVoteArcs.set(voterName, trail);
1517
+
1518
+ // Also add to a separate list for animation updates
1519
+ if (!this._animatingTrails) this._animatingTrails = [];
1520
+ this._animatingTrails.push(trail);
1521
+ }
1522
+
1523
+ _updateTargetRing(targetName, voteCount) {
1524
+ const target = this._playerObjects.get(targetName);
1525
+ if (!target) return;
1526
+
1527
+ let ringData = this._activeTargetRings.get(targetName);
1528
+
1529
+ if (voteCount > 0 && !ringData) {
1530
+ const geometry = new this._THREE.RingGeometry(2, 2.2, 32);
1531
+ const material = new this._THREE.MeshBasicMaterial({
1532
+ color: 0x00ffff,
1533
+ transparent: true,
1534
+ opacity: 0, // Start invisible, fade in
1535
+ side: this._THREE.DoubleSide,
1536
+ });
1537
+ const ring = new this._THREE.Mesh(geometry, material);
1538
+ ring.position.copy(target.container.position);
1539
+ ring.position.y = 0.1;
1540
+ ring.rotation.x = -Math.PI / 2;
1541
+
1542
+ this._targetRingsGroup.add(ring);
1543
+ ringData = { ring, material, targetOpacity: 0 };
1544
+ this._activeTargetRings.set(targetName, ringData);
1545
+ }
1546
+
1547
+ if (ringData) {
1548
+ if (voteCount > 0) {
1549
+ ringData.targetOpacity = 0.3 + Math.min(voteCount * 0.2, 0.7);
1550
+ } else {
1551
+ ringData.targetOpacity = 0;
1552
+ }
1553
+ }
1554
+ }
1555
+
1556
+ updateVoteVisuals(votes, clearAll = false) {
1557
+ if (!this._playerObjects || this._playerObjects.size === 0) return;
1558
+
1559
+ if (clearAll) {
1560
+ votes.clear();
1561
+ }
1562
+
1563
+ // Remove arcs from players who are no longer voting or if clearing all
1564
+ this._activeVoteArcs.forEach((trail, voterName) => {
1565
+ if (!votes.has(voterName)) {
1566
+ this._votingArcsGroup.remove(trail.particles);
1567
+ this._activeVoteArcs.delete(voterName);
1568
+ if (this._animatingTrails) {
1569
+ this._animatingTrails = this._animatingTrails.filter(t => t !== trail);
1570
+ }
1571
+ }
1572
+ });
1573
+
1574
+
1575
+ // Update existing arcs or create new ones
1576
+ votes.forEach((voteData, voterName) => {
1577
+ const { target: targetName, type } = voteData;
1578
+ const existingTrail = this._activeVoteArcs.get(voterName);
1579
+
1580
+ let color = 0x00ffff; // Default to cyan
1581
+ if (type === 'night_vote') color = 0xff0000; // Red
1582
+ else if (type === 'doctor_heal_action') color = 0x00ff00; // Green
1583
+ else if (type === 'seer_inspection') color = 0x800080; // Purple
1584
+
1585
+ if (existingTrail) {
1586
+ if (existingTrail.target !== targetName) {
1587
+ this._votingArcsGroup.remove(existingTrail.particles);
1588
+ if (this._animatingTrails) {
1589
+ this._animatingTrails = this._animatingTrails.filter(t => t !== existingTrail);
1590
+ }
1591
+ this._createVoteParticleTrail(voterName, targetName, color);
1592
+ }
1593
+ } else {
1594
+ this._createVoteParticleTrail(voterName, targetName, color);
1595
+ }
1596
+ });
1597
+
1598
+ // Update target rings
1599
+ const targetVoteCounts = new Map();
1600
+ votes.forEach((voteData) => {
1601
+ const { target: targetName } = voteData;
1602
+ targetVoteCounts.set(targetName, (targetVoteCounts.get(targetName) || 0) + 1);
1603
+ });
1604
+
1605
+ this._playerObjects.forEach((player, playerName) => {
1606
+ this._updateTargetRing(playerName, targetVoteCounts.get(playerName) || 0);
1607
+ });
1608
+ }
1609
+
1610
+ updatePhase(phase) {
1611
+ if (!this._scene) return;
1612
+
1613
+ // Handle various phase formats (DAY, NIGHT, or lowercase)
1614
+ const normalizedPhase = (phase || 'DAY').toUpperCase();
1615
+
1616
+ // Calculate target phase value (0 = day, 1 = night)
1617
+ const targetPhase = normalizedPhase === 'NIGHT' ? 1.0 : 0.0;
1618
+
1619
+ // Initialize transition system if not exists
1620
+ if (!this._phaseTransition) {
1621
+ this._phaseTransition = {
1622
+ current: targetPhase,
1623
+ target: targetPhase,
1624
+ speed: 0.05 // Increased transition speed for testing
1625
+ };
1626
+ // Immediately set to target on first call
1627
+ this._updateSceneForPhase(targetPhase);
1628
+ } else if (this._phaseTransition.target !== targetPhase) {
1629
+ // Only update if phase actually changed
1630
+ this._phaseTransition.target = targetPhase;
1631
+ }
1632
+ }
1633
+
1634
+ _updateSceneForPhase(phaseValue) {
1635
+ const THREE = this._THREE;
1636
+
1637
+ // Update renderer tone mapping for day/night mood
1638
+ if (this._threejs) {
1639
+ this._threejs.toneMappingExposure = 1.2 - phaseValue * 0.5; // Darker at night
1640
+ }
1641
+
1642
+ // Smoothly interpolate lighting
1643
+ if (this._mainLight) {
1644
+ const nightColor = new THREE.Color(0x6666cc); // More blue at night
1645
+ const dayColor = new THREE.Color(0xffffcc);
1646
+ this._mainLight.color.copy(dayColor).lerp(nightColor, phaseValue);
1647
+ this._mainLight.intensity = 1.8 - phaseValue * 1.0; // Much dimmer at night
1648
+
1649
+ // Animate light position for sun/moon movement
1650
+ const angle = phaseValue * Math.PI * 0.3;
1651
+ this._mainLight.position.set(
1652
+ 30 * Math.cos(angle),
1653
+ 50 - phaseValue * 20,
1654
+ 20 * Math.sin(angle)
1655
+ );
1656
+ }
1657
+
1658
+ if (this._rimLight) {
1659
+ const nightColor = new THREE.Color(0x6666ff);
1660
+ const dayColor = new THREE.Color(0xffcc99);
1661
+ this._rimLight.color.copy(dayColor).lerp(nightColor, phaseValue);
1662
+ this._rimLight.intensity = 0.6 + phaseValue * 0.4;
1663
+ }
1664
+
1665
+ if (this._hemiLight) {
1666
+ const nightSkyColor = new THREE.Color(0x4a4a6a);
1667
+ const daySkyColor = new THREE.Color(0x87ceeb);
1668
+ const nightGroundColor = new THREE.Color(0x1e1e3f);
1669
+ const dayGroundColor = new THREE.Color(0x8b7355);
1670
+
1671
+ this._hemiLight.color.copy(daySkyColor).lerp(nightSkyColor, phaseValue);
1672
+ this._hemiLight.groundColor.copy(dayGroundColor).lerp(nightGroundColor, phaseValue);
1673
+ this._hemiLight.intensity = 0.8 - phaseValue * 0.4;
1674
+ }
1675
+
1676
+ // Update ambient light
1677
+ const ambientLight = this._scene.getObjectByName('ambientLight');
1678
+ if (ambientLight) {
1679
+ const nightColor = new THREE.Color(0x404080);
1680
+ const dayColor = new THREE.Color(0x606090);
1681
+ ambientLight.color.copy(dayColor).lerp(nightColor, phaseValue);
1682
+ ambientLight.intensity = 0.4 + phaseValue * 0.1;
1683
+ }
1684
+
1685
+ // Smoothly transition fog - more dramatic change
1686
+ if (this._scene.fog) {
1687
+ const nightFogColor = new THREE.Color(0x050515); // Very dark blue at night
1688
+ const dayFogColor = new THREE.Color(0x2a2a4a); // Lighter blue during day
1689
+ this._scene.fog.color.copy(dayFogColor).lerp(nightFogColor, phaseValue);
1690
+ this._scene.fog.density = 0.01 + phaseValue * 0.015; // Denser fog at night
1691
+ }
1692
+
1693
+ // Update skybox
1694
+ this._updateSkybox(phaseValue);
1695
+
1696
+ // Update stars visibility
1697
+ if (this._starsMaterial) {
1698
+ this._starsMaterial.uniforms.phase.value = phaseValue;
1699
+ }
1700
+
1701
+ // Update atmosphere shader
1702
+ if (this._atmospherePass) {
1703
+ this._atmospherePass.uniforms.phase.value = phaseValue;
1704
+ }
1705
+
1706
+ // Update particle colors based on phase
1707
+ if (this._particles && this._particles.geometry.attributes.color) {
1708
+ const colors = this._particles.geometry.attributes.color.array;
1709
+ for (let i = 0; i < colors.length; i += 3) {
1710
+ // Shift particle hue based on phase
1711
+ const baseHue = 0.5 + Math.random() * 0.3; // Base blue-purple range
1712
+ const phaseShift = phaseValue * 0.1; // Shift towards purple at night
1713
+ const hue = baseHue + phaseShift;
1714
+ const saturation = 0.8 - phaseValue * 0.2; // Less saturated at night
1715
+ const lightness = 0.6 - phaseValue * 0.2; // Darker at night
1716
+
1717
+ const color = new THREE.Color().setHSL(hue, saturation, lightness);
1718
+ colors[i] = color.r;
1719
+ colors[i + 1] = color.g;
1720
+ colors[i + 2] = color.b;
1721
+ }
1722
+ this._particles.geometry.attributes.color.needsUpdate = true;
1723
+ }
1724
+
1725
+ // Update bloom intensity based on phase - moderate during day, more at night
1726
+ if (this._bloomPass) {
1727
+ this._bloomPass.strength = 0.35 + phaseValue * 0.35; // Moderate bloom during day (0.35), stronger at night (0.7)
1728
+ this._bloomPass.radius = 0.6 + phaseValue * 0.3; // Good radius during day (0.6), wider at night (0.9)
1729
+ this._bloomPass.threshold = 0.4 - phaseValue * 0.15; // Balanced threshold
1730
+ }
1731
+ }
1732
+
1733
+ _createNameplate(name, displayName, imageUrl, CSS2DObject) {
1734
+ const container = document.createElement('div');
1735
+ container.style.backgroundColor = 'rgba(255, 255, 255, 0)';
1736
+ container.style.padding = '6px 10px'; // Slightly smaller padding
1737
+ container.style.borderRadius = '8px';
1738
+ container.style.display = 'flex';
1739
+ container.style.alignItems = 'center';
1740
+ container.style.justifyContent = 'center';
1741
+ container.style.gap = '8px'; // Reduced gap
1742
+ container.style.textAlign = 'center';
1743
+
1744
+ const img = document.createElement('img');
1745
+ img.src = imageUrl;
1746
+ img.style.width = '40px'; // Reduced from 60px
1747
+ img.style.height = '40px'; // Reduced from 60px
1748
+ img.style.borderRadius = '50%';
1749
+ img.style.objectFit = 'cover';
1750
+ img.style.backgroundColor = 'white';
1751
+ img.style.border = '2px solid rgba(255, 255, 255, 0.3)';
1752
+
1753
+ const textContainer = document.createElement('div');
1754
+ textContainer.style.display = 'flex';
1755
+ textContainer.style.flexDirection = 'column';
1756
+ textContainer.style.alignItems = 'center';
1757
+
1758
+ const nameText = document.createElement('div');
1759
+ nameText.textContent = name;
1760
+ nameText.style.color = 'white';
1761
+ nameText.style.fontFamily = 'Arial, sans-serif';
1762
+ nameText.style.fontSize = '14px';
1763
+ nameText.style.fontWeight = '500';
1764
+ textContainer.appendChild(nameText);
1765
+
1766
+ if (displayName && displayName !== "" && displayName !== name) {
1767
+ const displayNameText = document.createElement('div');
1768
+ displayNameText.textContent = displayName;
1769
+ displayNameText.style.color = 'grey';
1770
+ displayNameText.style.fontSize = '12px';
1771
+ displayNameText.style.fontFamily = 'Arial, sans-serif';
1772
+ displayNameText.style.marginTop = '4px';
1773
+ textContainer.appendChild(displayNameText);
1774
+ }
1775
+
1776
+ container.appendChild(img);
1777
+ container.appendChild(textContainer);
1778
+
1779
+ const label = new CSS2DObject(container);
1780
+ return label;
1781
+ }
1782
+
1783
+ _FrameGroup(group, THREE) {
1784
+ const box = new THREE.Box3().setFromObject(group);
1785
+ const center = box.getCenter(new THREE.Vector3());
1786
+ const size = box.getSize(new THREE.Vector3());
1787
+
1788
+ const maxDim = Math.max(size.x, size.y, size.z);
1789
+ const fov = this._camera.fov * (Math.PI / 180);
1790
+
1791
+ let cameraZ = Math.abs(maxDim / Math.tan(fov / 2));
1792
+ cameraZ *= 0.5;
1793
+
1794
+ this._camera.position.set(center.x, center.y, center.z - cameraZ);
1795
+
1796
+ const shiftY = size.y / 2.5;
1797
+ this._camera.position.y += shiftY;
1798
+
1799
+ const newTarget = center.clone();
1800
+ newTarget.y += shiftY;
1801
+ this._controls.target.copy(newTarget);
1802
+ this._controls.update();
1803
+ }
1804
+
1805
+ _NormalizeModel(model, THREE) {
1806
+ const box = new THREE.Box3().setFromObject(model);
1807
+ const size = box.getSize(new THREE.Vector3());
1808
+ const center = box.getCenter(new THREE.Vector3());
1809
+ model.position.copy(center).negate();
1810
+ const wrapper = new THREE.Group();
1811
+ wrapper.add(model);
1812
+ const scale = 1.0 / size.y;
1813
+ wrapper.scale.set(scale, scale, scale);
1814
+ return wrapper;
1815
+ }
1816
+
1817
+ _RAF() {
1818
+ requestAnimationFrame((time) => {
1819
+ // Animate phase transition with visual feedback
1820
+ if (this._phaseTransition) {
1821
+ const diff = this._phaseTransition.target - this._phaseTransition.current;
1822
+ if (Math.abs(diff) > 0.001) {
1823
+ this._phaseTransition.current += diff * this._phaseTransition.speed;
1824
+ this._updateSceneForPhase(this._phaseTransition.current);
1825
+ }
1826
+ }
1827
+
1828
+ // Update time-based uniforms
1829
+ if (this._particleMaterial) {
1830
+ this._particleMaterial.uniforms.time.value = time * 0.001;
1831
+ }
1832
+ if (this._atmospherePass) {
1833
+ this._atmospherePass.uniforms.time.value = time * 0.001;
1834
+ }
1835
+
1836
+ // Animate particle system with phase-aware movement
1837
+ if (this._particles) {
1838
+ const phaseValue = this._phaseTransition ? this._phaseTransition.current : 0;
1839
+ // Slower rotation at night
1840
+ this._particles.rotation.y = time * 0.0001 * (1 - phaseValue * 0.5);
1841
+
1842
+ const positions = this._particles.geometry.attributes.position.array;
1843
+ for (let i = 0; i < positions.length; i += 3) {
1844
+ // More gentle movement at night
1845
+ const movementScale = 1 - phaseValue * 0.5;
1846
+ positions[i + 1] += Math.sin(time * 0.001 + positions[i] * 0.01) * 0.02 * movementScale;
1847
+ // Wrap around if particles fall too low
1848
+ if (positions[i + 1] < 0) {
1849
+ positions[i + 1] = 35;
1850
+ }
1851
+ }
1852
+ this._particles.geometry.attributes.position.needsUpdate = true;
1853
+ }
1854
+
1855
+ // Animate stars twinkling
1856
+ if (this._stars && this._phaseTransition && this._phaseTransition.current > 0.5) {
1857
+ const sizes = this._stars.geometry.attributes.size.array;
1858
+ for (let i = 0; i < sizes.length; i++) {
1859
+ sizes[i] = (Math.random() * 2 + 0.5) * (0.8 + Math.sin(time * 0.001 + i) * 0.2);
1860
+ }
1861
+ this._stars.geometry.attributes.size.needsUpdate = true;
1862
+ }
1863
+
1864
+ // Use performance.now() for more precise animation timing
1865
+ const now = performance.now();
1866
+
1867
+ if (this._cameraAnimation) {
1868
+ const anim = this._cameraAnimation;
1869
+ const elapsed = now - anim.startTime;
1870
+ let progress = Math.min(elapsed / anim.duration, 1.0);
1871
+
1872
+ // Apply easing function
1873
+ const easedProgress = anim.ease(progress);
1874
+
1875
+ // Interpolate camera position and controls target
1876
+ this._camera.position.lerpVectors(anim.startPos, anim.endPos, easedProgress);
1877
+ this._controls.target.lerpVectors(anim.startTarget, anim.endTarget, easedProgress);
1878
+ this._controls.update();
1879
+
1880
+ // If animation is complete, clear it
1881
+ if (progress >= 1.0) {
1882
+ this._cameraAnimation = null;
1883
+ }
1884
+ }
1885
+
1886
+ // Animate speaking sound waves
1887
+ this._speakingAnimations = this._speakingAnimations.filter(anim => {
1888
+ const elapsedTime = now - anim.startTime;
1889
+ if (elapsedTime >= anim.duration) {
1890
+ // Animation is over, remove the mesh from the scene
1891
+ if (anim.mesh.parent) {
1892
+ anim.mesh.parent.remove(anim.mesh);
1893
+ }
1894
+ // Clean up Three.js objects to free memory
1895
+ anim.mesh.geometry.dispose();
1896
+ anim.mesh.material.dispose();
1897
+ return false; // Remove from the animations array
1898
+ }
1899
+
1900
+ // Calculate animation progress (from 0.0 to 1.0)
1901
+ const progress = elapsedTime / anim.duration;
1902
+
1903
+ // Make the wave expand and fade out
1904
+ anim.mesh.scale.setScalar(1 + progress * 5);
1905
+ anim.mesh.material.opacity = 0.8 * (1 - progress);
1906
+
1907
+ return true; // Keep the animation in the array
1908
+ });
1909
+
1910
+ // Animate player objects with enhanced effects
1911
+ if (this._playerObjects) {
1912
+ this._playerObjects.forEach((player, name) => {
1913
+ if (player.isAlive) {
1914
+ // Enhanced floating animation for alive players
1915
+ const floatOffset = Math.sin(time * 0.001 + player.baseAngle) * 0.2;
1916
+ const bobOffset = Math.cos(time * 0.0015 + player.baseAngle * 2) * 0.05;
1917
+ player.container.position.y = floatOffset + bobOffset;
1918
+
1919
+ // More dynamic orb rotation
1920
+ player.orb.rotation.y = time * 0.003;
1921
+ player.orb.rotation.x = Math.sin(time * 0.002) * 0.15;
1922
+ player.orb.rotation.z = Math.cos(time * 0.0025) * 0.1;
1923
+
1924
+ // Enhanced glow animation
1925
+ if (player.glow && player.glow.visible) {
1926
+ player.glow.rotation.y = -time * 0.002;
1927
+ const glowScale = 1 + Math.sin(time * 0.004 + player.baseAngle) * 0.15;
1928
+ player.glow.scale.setScalar(glowScale);
1929
+
1930
+ // Pulsing emissive intensity
1931
+ const pulseIntensity = 0.3 + Math.sin(time * 0.005 + player.baseAngle) * 0.1;
1932
+ player.glow.material.emissiveIntensity = pulseIntensity;
1933
+ }
1934
+
1935
+ // Enhanced pulse effect for active players
1936
+ if (player.container.scale.x > 1.0) {
1937
+ const pulseScale = 1.05 + Math.sin(time * 0.008) * 0.08;
1938
+ player.container.scale.setScalar(pulseScale);
1939
+ }
1940
+
1941
+ // Enhanced breathing effect
1942
+ if (player.body) {
1943
+ const breathScale = 1 + Math.sin(time * 0.002 + player.baseAngle) * 0.03;
1944
+ player.body.scale.y = breathScale;
1945
+ if (player.shoulders) {
1946
+ player.shoulders.scale.y = 0.6 * breathScale;
1947
+ }
1948
+ }
1949
+
1950
+ // Subtle head movement
1951
+ if (player.head) {
1952
+ player.head.rotation.y = Math.sin(time * 0.001 + player.baseAngle) * 0.1;
1953
+ }
1954
+ } else {
1955
+ // Dead players have reduced animation
1956
+ if (player.orb) {
1957
+ player.orb.rotation.y = time * 0.0008;
1958
+ }
1959
+ }
1960
+ });
1961
+ }
1962
+
1963
+ // Animate voting trails
1964
+ if (this._animatingTrails) {
1965
+ this._animatingTrails.forEach(trail => trail.update());
1966
+ }
1967
+
1968
+ // Animate target rings
1969
+ if (this._activeTargetRings) {
1970
+ this._activeTargetRings.forEach((ringData, targetName) => {
1971
+ const diff = ringData.targetOpacity - ringData.material.opacity;
1972
+ if (Math.abs(diff) > 0.01) {
1973
+ ringData.material.opacity += diff * 0.1;
1974
+ } else if (ringData.targetOpacity === 0 && ringData.material.opacity > 0) {
1975
+ this._targetRingsGroup.remove(ringData.ring);
1976
+ this._activeTargetRings.delete(targetName);
1977
+ }
1978
+ });
1979
+ }
1980
+
1981
+ // Use post-processing composer if available, otherwise fallback to direct render
1982
+ if (this._composer) {
1983
+ this._composer.render();
1984
+ } else {
1985
+ this._threejs.render(this._scene, this._camera);
1986
+ }
1987
+ this._labelRenderer.render(this._scene, this._camera);
1988
+ this._RAF();
1989
+ });
1990
+ }
1991
+ }
1992
+
1993
+ setupScene(BasicWorldDemo);
1994
+ } catch (error) {
1995
+ console.error("Failed to load Three.js modules:", error);
1996
+ parent.textContent = "Error loading 3D assets. Please refresh.";
1997
+ }
1998
+ };
1999
+
2000
+ loadAndSetup();
2001
+ }
2002
+
2003
+ function setupScene(BasicWorldDemo) {
2004
+ if (threeState.initialized) return;
2005
+ threeState.demo = new BasicWorldDemo({ parent, width, height });
2006
+ threeState.initialized = true;
2007
+ }
2008
+
2009
+ function updateSceneFromGameState(gameState, playerMap, actingPlayerName) {
2010
+ if (!threeState.demo || !threeState.demo._playerObjects) return;
2011
+
2012
+ const logUpToCurrentStep = gameState.eventLog;
2013
+ const lastEvent = logUpToCurrentStep.length > 0 ? logUpToCurrentStep[logUpToCurrentStep.length - 1] : null;
2014
+
2015
+ // Determine correct phase from the last event log entry
2016
+ let phase = gameState.game_state_phase; // Default
2017
+ if (lastEvent && lastEvent.phase) {
2018
+ phase = lastEvent.phase;
2019
+ }
2020
+
2021
+ // Update player statuses
2022
+ gameState.players.forEach(player => {
2023
+ const playerObj = threeState.demo._playerObjects.get(player.name);
2024
+ if (!playerObj) return;
2025
+
2026
+ const threatLevel = gameState.playerThreatLevels.get(player.name) || 0;
2027
+
2028
+ let primaryStatus = 'default'; // Default for alive players in daytime.
2029
+ if (!player.is_alive) {
2030
+ primaryStatus = 'dead';
2031
+ } else if (player.role === 'Werewolf' && phase.toUpperCase() === 'NIGHT') {
2032
+ primaryStatus = 'werewolf';
2033
+ } else if (player.role === 'Doctor' && phase.toUpperCase() === 'NIGHT') {
2034
+ primaryStatus = 'doctor';
2035
+ } else if (player.role === 'Seer' && phase.toUpperCase() === 'NIGHT') {
2036
+ primaryStatus = 'seer';
2037
+ }
2038
+
2039
+ threeState.demo.updatePlayerStatus(player.name, primaryStatus, threatLevel);
2040
+ });
2041
+
2042
+ // Update phase lighting
2043
+ threeState.demo.updatePhase(phase);
2044
+
2045
+ // --- Vote Visualization Logic ---
2046
+ const currentVotes = new Map();
2047
+
2048
+ // Find the start of the current voting/action session
2049
+ const lastNightStart = logUpToCurrentStep.findLastIndex(e => e.type === 'phase_divider' && e.divider === 'NIGHT START');
2050
+ const lastDayVoteStart = logUpToCurrentStep.findLastIndex(e => e.type === 'phase_divider' && e.divider === 'DAY VOTE START');
2051
+ const sessionStartIndex = Math.max(lastNightStart, lastDayVoteStart);
2052
+
2053
+ let isVotingSession = false;
2054
+ if (sessionStartIndex > -1) {
2055
+ const lastOutcomeEventIndex = logUpToCurrentStep.findLastIndex(e => e.type === 'exile' || e.type === 'elimination' || e.type === 'save');
2056
+ // A session is active if it started after the last outcome, OR if the outcome is the current event.
2057
+ if (sessionStartIndex > lastOutcomeEventIndex || (lastOutcomeEventIndex > -1 && lastOutcomeEventIndex === logUpToCurrentStep.length - 1)) {
2058
+ isVotingSession = true;
2059
+ }
2060
+ }
2061
+
2062
+ if (isVotingSession) {
2063
+ const alivePlayerNames = new Set(gameState.players.filter(p => p.is_alive).map(p => p.name));
2064
+ const relevantEvents = logUpToCurrentStep.slice(sessionStartIndex);
2065
+ for (const event of relevantEvents) {
2066
+ if (event.type === 'vote' || event.type === 'night_vote' || event.type === 'doctor_heal_action' || event.type === 'seer_inspection') {
2067
+ if (alivePlayerNames.has(event.actor_id)) {
2068
+ currentVotes.set(event.actor_id, { target: event.target, type: event.type });
2069
+ }
2070
+ } else if (event.type === 'timeout') {
2071
+ currentVotes.delete(event.actor_id);
2072
+ }
2073
+ }
2074
+ }
2075
+
2076
+ const clearVotingVisuals = !isVotingSession;
2077
+ threeState.demo.updateVoteVisuals(currentVotes, clearVotingVisuals);
2078
+
2079
+
2080
+ // Spotlight logic for night actions
2081
+ if (threeState.demo._spotLight) {
2082
+ const lastEvent = gameState.eventLog[gameState.eventLog.length - 1];
2083
+ const nightActor = (gameState.game_state_phase === 'NIGHT' && lastEvent && lastEvent.actor_id && ['WerewolfNightVoteDataEntry', 'DoctorHealActionDataEntry', 'SeerInspectActionDataEntry'].includes(lastEvent.dataType)) ? lastEvent.actor_id : null;
2084
+
2085
+ if (nightActor) {
2086
+ const actorPlayer = threeState.demo._playerObjects.get(nightActor);
2087
+ if (actorPlayer) {
2088
+ const targetPosition = actorPlayer.container.position.clone();
2089
+ threeState.demo._spotLight.target.position.copy(targetPosition);
2090
+ threeState.demo._spotLight.position.set(targetPosition.x, targetPosition.y + 20, targetPosition.z + 5);
2091
+ threeState.demo._spotLight.visible = true;
2092
+ } else {
2093
+ threeState.demo._spotLight.visible = false;
2094
+ }
2095
+ } else {
2096
+ threeState.demo._spotLight.visible = false;
2097
+ }
2098
+ }
2099
+
2100
+ // Handle animation for the current event actor
2101
+ if (lastEvent) {
2102
+ if (lastEvent.event_name === 'moderator_announcement') {
2103
+ // Moderator is speaking, expand all alive players
2104
+ gameState.players.forEach(player => {
2105
+ if (player.is_alive) {
2106
+ threeState.demo.updatePlayerActive(player.name);
2107
+ }
2108
+ });
2109
+ } else if (lastEvent.actor_id && playerMap.has(lastEvent.actor_id)) {
2110
+ // A player is the actor
2111
+ const actorName = lastEvent.actor_id;
2112
+ threeState.demo.updatePlayerActive(actorName);
2113
+
2114
+ // If the action was speaking, trigger the sound wave animation
2115
+ if (lastEvent.type === 'chat' && threeState.demo.triggerSpeakingAnimation) {
2116
+ threeState.demo.triggerSpeakingAnimation(actorName);
2117
+ }
2118
+ }
2119
+ }
2120
+ }
2121
+
2122
+ // --- CSS for the UI ---
2123
+ const css = `
2124
+ /* Game Status Scoreboard */
2125
+ .game-scoreboard {
2126
+ position: fixed;
2127
+ top: 70px;
2128
+ left: 50%;
2129
+ transform: translateX(-50%);
2130
+ z-index: 999;
2131
+ background: linear-gradient(135deg, rgba(33, 40, 54, 0.95), rgba(44, 52, 68, 0.95));
2132
+ backdrop-filter: blur(15px);
2133
+ border: 1px solid rgba(116, 185, 255, 0.3);
2134
+ border-radius: 12px;
2135
+ padding: 12px 20px;
2136
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
2137
+ display: flex;
2138
+ gap: 20px;
2139
+ align-items: center;
2140
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
2141
+ pointer-events: none;
2142
+ }
2143
+
2144
+ .scoreboard-item {
2145
+ display: flex;
2146
+ flex-direction: column;
2147
+ align-items: center;
2148
+ padding: 0 10px;
2149
+ border-right: 1px solid rgba(116, 185, 255, 0.2);
2150
+ }
2151
+
2152
+ .scoreboard-item:last-child {
2153
+ border-right: none;
2154
+ }
2155
+
2156
+ .scoreboard-label {
2157
+ font-size: 0.75rem;
2158
+ color: var(--text-muted);
2159
+ text-transform: uppercase;
2160
+ letter-spacing: 0.05em;
2161
+ margin-bottom: 4px;
2162
+ font-weight: 500;
2163
+ }
2164
+
2165
+ .scoreboard-value {
2166
+ font-size: 1.1rem;
2167
+ color: var(--text-primary);
2168
+ font-weight: 600;
2169
+ }
2170
+
2171
+ .scoreboard-value.alive {
2172
+ color: #00b894;
2173
+ }
2174
+
2175
+ .scoreboard-value.dead {
2176
+ color: #e17055;
2177
+ }
2178
+
2179
+ .scoreboard-value.werewolf {
2180
+ color: #e17055;
2181
+ }
2182
+
2183
+ .scoreboard-value.villager {
2184
+ color: #74b9ff;
2185
+ }
2186
+
2187
+ .scoreboard-action {
2188
+ background: linear-gradient(135deg, rgba(116, 185, 255, 0.2), rgba(116, 185, 255, 0.1));
2189
+ border: 1px solid rgba(116, 185, 255, 0.3);
2190
+ border-radius: 8px;
2191
+ padding: 6px 12px;
2192
+ font-size: 0.9rem;
2193
+ color: #74b9ff;
2194
+ font-weight: 500;
2195
+ animation: pulse 2s infinite;
2196
+ }
2197
+
2198
+ @keyframes pulse {
2199
+ 0%, 100% { opacity: 1; }
2200
+ 50% { opacity: 0.7; }
2201
+ }
2202
+
2203
+ /* Phase Indicator */
2204
+ .phase-indicator {
2205
+ position: fixed;
2206
+ top: 60px;
2207
+ left: 50px;
2208
+ transform: translateX(-50%);
2209
+ z-index: 1000;
2210
+ padding: 12px 24px;
2211
+ border-radius: 30px;
2212
+ font-size: 1.2rem;
2213
+ font-weight: 600;
2214
+ letter-spacing: 0.05em;
2215
+ transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
2216
+ pointer-events: none;
2217
+ backdrop-filter: blur(10px);
2218
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
2219
+ scale: 0.6;
2220
+ }
2221
+
2222
+ .phase-indicator.day {
2223
+ background: linear-gradient(135deg, rgba(255, 220, 100, 0.9), rgba(255, 180, 50, 0.9));
2224
+ color: #2d3436;
2225
+ border: 2px solid rgba(255, 255, 255, 0.5);
2226
+ }
2227
+
2228
+ .phase-indicator.night {
2229
+ background: linear-gradient(135deg, rgba(30, 30, 60, 0.9), rgba(60, 60, 120, 0.9));
2230
+ color: #f8f9fa;
2231
+ border: 2px solid rgba(100, 100, 200, 0.5);
2232
+ }
2233
+
2234
+ .phase-indicator .phase-icon {
2235
+ display: inline-block;
2236
+ margin-right: 8px;
2237
+ font-size: 1.4rem;
2238
+ vertical-align: middle;
2239
+ }
2240
+
2241
+ :root {
2242
+ --night-bg: linear-gradient(135deg, #1a1a2e, #16213e);
2243
+ --day-bg: linear-gradient(135deg, #74b9ff, #0984e3);
2244
+ --night-text: #f8f9fa;
2245
+ --day-text: #2d3436;
2246
+ --dead-filter: grayscale(100%) brightness(40%) contrast(0.8);
2247
+ --active-border: #fdcb6e;
2248
+ --active-glow: rgba(253, 203, 110, 0.4);
2249
+ --werewolf-color: #e17055;
2250
+ --villager-color: #00b894;
2251
+ --doctor-color: #6c5ce7;
2252
+ --seer-color: #fd79a8;
2253
+ --panel-bg: rgba(33, 40, 54, 0.95);
2254
+ --panel-border: rgba(116, 185, 255, 0.2);
2255
+ --hover-bg: rgba(116, 185, 255, 0.1);
2256
+ --card-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
2257
+ --text-primary: #f8f9fa;
2258
+ --text-secondary: #b2bec3;
2259
+ --text-muted: #74b9ff;
2260
+ }
2261
+
2262
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
2263
+
2264
+ .werewolf-parent {
2265
+ position: relative;
2266
+ overflow: hidden;
2267
+ width: 100%;
2268
+ height: 100%;
2269
+ background: radial-gradient(ellipse at center, #0f1419 0%, #000000 100%);
2270
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2271
+ }
2272
+
2273
+ .main-container {
2274
+ position: absolute;
2275
+ top: 0;
2276
+ left: 0;
2277
+ right: 0;
2278
+ bottom: 0;
2279
+ z-index: 2;
2280
+ pointer-events: none;
2281
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2282
+ color: var(--text-primary);
2283
+ font-weight: 400;
2284
+ line-height: 1.5;
2285
+ -webkit-font-smoothing: antialiased;
2286
+ -moz-osx-font-smoothing: grayscale;
2287
+ }
2288
+
2289
+ /* Enhanced Panel Styling */
2290
+ .left-panel, .right-panel {
2291
+ position: fixed;
2292
+ top: 54px;
2293
+ max-height: calc(100vh - 124px);
2294
+ background: var(--panel-bg);
2295
+ backdrop-filter: blur(20px) saturate(1.5);
2296
+ border-radius: 16px;
2297
+ border: 1px solid var(--panel-border);
2298
+ padding: 20px;
2299
+ display: flex;
2300
+ flex-direction: column;
2301
+ box-sizing: border-box;
2302
+ pointer-events: auto;
2303
+ box-shadow: var(--card-shadow), 0 0 40px rgba(116, 185, 255, 0.05);
2304
+ overflow: hidden;
2305
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2306
+ }
2307
+
2308
+ .left-panel {
2309
+ left: 20px;
2310
+ width: 320px;
2311
+ }
2312
+
2313
+ .right-panel {
2314
+ right: 20px;
2315
+ width: 420px;
2316
+ }
2317
+
2318
+ .left-panel:hover, .right-panel:hover {
2319
+ border-color: rgba(116, 185, 255, 0.3);
2320
+ box-shadow: var(--card-shadow), 0 0 60px rgba(116, 185, 255, 0.08);
2321
+ }
2322
+
2323
+ /* Enhanced Headers */
2324
+ .right-panel h1, #player-list-area h1 {
2325
+ margin: 0 0 20px 0;
2326
+ font-size: 1.75rem;
2327
+ font-weight: 600;
2328
+ color: var(--text-primary);
2329
+ position: relative;
2330
+ padding-bottom: 15px;
2331
+ flex-shrink: 0;
2332
+ display: flex;
2333
+ justify-content: center;
2334
+ align-items: center;
2335
+ gap: 10px;
2336
+ }
2337
+
2338
+ .right-panel h1 > span, #player-list-area h1 > span {
2339
+ background: linear-gradient(135deg, #74b9ff, #0984e3);
2340
+ -webkit-background-clip: text;
2341
+ -webkit-text-fill-color: transparent;
2342
+ background-clip: text;
2343
+ }
2344
+
2345
+ .right-panel h1::after, #player-list-area h1::after {
2346
+ content: '';
2347
+ position: absolute;
2348
+ bottom: 0;
2349
+ left: 50%;
2350
+ transform: translateX(-50%);
2351
+ width: 60px;
2352
+ height: 3px;
2353
+ background: linear-gradient(90deg, transparent, #74b9ff, transparent);
2354
+ border-radius: 2px;
2355
+ }
2356
+
2357
+ #global-reasoning-toggle {
2358
+ background: none;
2359
+ border: none;
2360
+ cursor: pointer;
2361
+ padding: 4px;
2362
+ border-radius: 50%;
2363
+ display: inline-flex;
2364
+ align-items: center;
2365
+ justify-content: center;
2366
+ color: var(--text-muted);
2367
+ transition: all 0.2s ease;
2368
+ }
2369
+ #global-reasoning-toggle:hover {
2370
+ background-color: var(--hover-bg);
2371
+ color: var(--text-primary);
2372
+ }
2373
+ #global-reasoning-toggle svg {
2374
+ stroke: currentColor;
2375
+ width: 20px;
2376
+ height: 20px;
2377
+ }
2378
+
2379
+ #global-audio-toggle {
2380
+ background: none;
2381
+ border: none;
2382
+ cursor: pointer;
2383
+ padding: 4px;
2384
+ border-radius: 50%;
2385
+ display: inline-flex;
2386
+ align-items: center;
2387
+ justify-content: center;
2388
+ color: var(--text-muted);
2389
+ transition: all 0.2s ease;
2390
+ font-size: 18px; /* For the emoji */
2391
+ vertical-align: middle; /* Align with the SVG icon */
2392
+ margin-left: 4px; /* Space from eye icon */
2393
+ }
2394
+ #global-audio-toggle:hover:not(.disabled) {
2395
+ background-color: var(--hover-bg);
2396
+ color: var(--text-primary);
2397
+ }
2398
+ #global-audio-toggle.disabled {
2399
+ color: #555; /* More dimmed */
2400
+ cursor: not-allowed;
2401
+ opacity: 0.5;
2402
+ }
2403
+ #global-audio-toggle.enabled {
2404
+ color: var(--text-primary); /* Brighter when enabled */
2405
+ }
2406
+
2407
+ .reset-view-btn {
2408
+ background: none;
2409
+ border: none;
2410
+ cursor: pointer;
2411
+ padding: 4px;
2412
+ border-radius: 50%;
2413
+ display: inline-flex;
2414
+ align-items: center;
2415
+ justify-content: center;
2416
+ color: var(--text-muted);
2417
+ transition: all 0.2s ease;
2418
+ margin-left: 8px; /* Add some space */
2419
+ }
2420
+ .reset-view-btn:hover {
2421
+ background-color: var(--hover-bg);
2422
+ color: var(--text-primary);
2423
+ }
2424
+ .reset-view-btn svg {
2425
+ stroke: currentColor;
2426
+ width: 20px;
2427
+ height: 20px;
2428
+ }
2429
+
2430
+ /* Enhanced Player List */
2431
+ #player-list-area {
2432
+ flex: 1;
2433
+ display: flex;
2434
+ flex-direction: column;
2435
+ min-height: 0;
2436
+ }
2437
+
2438
+ #player-list-container {
2439
+ overflow-y: auto;
2440
+ flex-grow: 1;
2441
+ padding-right: 8px;
2442
+ margin-right: -8px;
2443
+ }
2444
+
2445
+ #player-list-container::-webkit-scrollbar {
2446
+ width: 6px;
2447
+ }
2448
+
2449
+ #player-list-container::-webkit-scrollbar-track {
2450
+ background: rgba(255, 255, 255, 0.05);
2451
+ border-radius: 3px;
2452
+ }
2453
+
2454
+ #player-list-container::-webkit-scrollbar-thumb {
2455
+ background: rgba(116, 185, 255, 0.3);
2456
+ border-radius: 3px;
2457
+ }
2458
+
2459
+ #player-list-container::-webkit-scrollbar-thumb:hover {
2460
+ background: rgba(116, 185, 255, 0.5);
2461
+ }
2462
+
2463
+ #player-list {
2464
+ list-style: none;
2465
+ padding: 0;
2466
+ margin: 0;
2467
+ display: flex;
2468
+ flex-direction: column;
2469
+ gap: 12px;
2470
+ }
2471
+
2472
+ /* Enhanced Player Cards */
2473
+ .player-card {
2474
+ position: relative;
2475
+ display: flex;
2476
+ align-items: center;
2477
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
2478
+ padding: 16px;
2479
+ border-radius: 12px;
2480
+ border: 1px solid rgba(255, 255, 255, 0.1);
2481
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
2482
+ cursor: pointer;
2483
+ overflow: hidden;
2484
+ }
2485
+
2486
+ .player-card::before {
2487
+ content: '';
2488
+ position: absolute;
2489
+ left: 0;
2490
+ top: 0;
2491
+ bottom: 0;
2492
+ width: 4px;
2493
+ background: transparent;
2494
+ transition: all 0.3s ease;
2495
+ }
2496
+
2497
+ .player-card:hover {
2498
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06));
2499
+ border-color: rgba(116, 185, 255, 0.3);
2500
+ transform: translateY(-2px);
2501
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
2502
+ }
2503
+
2504
+ .player-card.active {
2505
+ background: linear-gradient(135deg, rgba(253, 203, 110, 0.15), rgba(253, 203, 110, 0.05));
2506
+ border-color: var(--active-border);
2507
+ box-shadow: 0 0 20px var(--active-glow), 0 4px 20px rgba(0, 0, 0, 0.1);
2508
+ }
2509
+
2510
+ .player-card.active::before {
2511
+ background: linear-gradient(180deg, var(--active-border), rgba(253, 203, 110, 0.5));
2512
+ }
2513
+
2514
+ .player-card.dead {
2515
+ opacity: 0.5;
2516
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
2517
+ filter: brightness(0.7);
2518
+ }
2519
+
2520
+ /* Enhanced Avatar */
2521
+ .avatar-container {
2522
+ position: relative;
2523
+ width: 40px;
2524
+ height: 40px;
2525
+ margin-right: 16px;
2526
+ flex-shrink: 0;
2527
+ }
2528
+
2529
+ .player-card .avatar {
2530
+ width: 100%;
2531
+ height: 100%;
2532
+ border-radius: 50%;
2533
+ object-fit: cover;
2534
+ background-color: #ffffff;
2535
+ border: 2px solid rgba(116, 185, 255, 0.2);
2536
+ transition: all 0.3s ease;
2537
+ }
2538
+
2539
+ .player-card:hover .avatar {
2540
+ border-color: rgba(116, 185, 255, 0.4);
2541
+ box-shadow: 0 0 15px rgba(116, 185, 255, 0.2);
2542
+ }
2543
+
2544
+ .player-card.active .avatar {
2545
+ border-color: var(--active-border);
2546
+ box-shadow: 0 0 15px var(--active-glow);
2547
+ }
2548
+
2549
+ .player-card.dead .avatar {
2550
+ filter: var(--dead-filter);
2551
+ border-color: rgba(255, 255, 255, 0.1);
2552
+ }
2553
+
2554
+ /* Enhanced Player Info */
2555
+ .player-info {
2556
+ flex-grow: 1;
2557
+ overflow: hidden;
2558
+ min-width: 0;
2559
+ }
2560
+
2561
+ .player-name {
2562
+ font-weight: 600;
2563
+ font-size: 1.1rem;
2564
+ margin-bottom: 4px;
2565
+ color: var(--text-primary);
2566
+ white-space: nowrap;
2567
+ overflow: hidden;
2568
+ text-overflow: ellipsis;
2569
+ letter-spacing: -0.01em;
2570
+ }
2571
+
2572
+ .player-role {
2573
+ font-size: 0.875rem;
2574
+ color: var(--text-secondary);
2575
+ font-weight: 500;
2576
+ display: flex;
2577
+ align-items: center;
2578
+ gap: 6px;
2579
+ }
2580
+
2581
+ .player-role.werewolf { color: var(--werewolf-color); }
2582
+ .player-role.villager { color: var(--villager-color); }
2583
+ .player-role.doctor { color: var(--doctor-color); }
2584
+ .player-role.seer { color: var(--seer-color); }
2585
+
2586
+ .display-name {
2587
+ font-size: 0.8em;
2588
+ color: #888;
2589
+ margin-left: 5px;
2590
+ }
2591
+
2592
+ /* Enhanced Threat Indicator */
2593
+ .threat-indicator {
2594
+ position: absolute;
2595
+ top: 12px;
2596
+ right: 12px;
2597
+ width: 8px;
2598
+ height: 8px;
2599
+ border-radius: 50%;
2600
+ background-color: transparent;
2601
+ transition: all 0.3s ease;
2602
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.3);
2603
+ }
2604
+
2605
+ /* Enhanced Chat/Event Log */
2606
+ #chat-log {
2607
+ list-style: none;
2608
+ padding: 0;
2609
+ margin: 0;
2610
+ flex-grow: 1;
2611
+ overflow-y: auto;
2612
+ padding-right: 8px;
2613
+ margin-right: -8px;
2614
+ }
2615
+
2616
+ #chat-log::-webkit-scrollbar {
2617
+ width: 6px;
2618
+ }
2619
+
2620
+ #chat-log::-webkit-scrollbar-track {
2621
+ background: rgba(255, 255, 255, 0.05);
2622
+ border-radius: 3px;
2623
+ }
2624
+
2625
+ #chat-log::-webkit-scrollbar-thumb {
2626
+ background: rgba(116, 185, 255, 0.3);
2627
+ border-radius: 3px;
2628
+ }
2629
+
2630
+ #chat-log::-webkit-scrollbar-thumb:hover {
2631
+ background: rgba(116, 185, 255, 0.5);
2632
+ }
2633
+
2634
+ #chat-log li.now-playing > .message-content > .balloon,
2635
+ #chat-log li.now-playing > .moderator-announcement-content,
2636
+ #chat-log li.now-playing.msg-entry {
2637
+ background: linear-gradient(135deg, rgba(253, 203, 110, 0.2), rgba(253, 203, 110, 0.1));
2638
+ border-color: #fdcb6e; /* A bright yellow */
2639
+ box-shadow: 0 0 5px rgba(253, 203, 110, 0.3);
2640
+ transition: all 0.2s ease-in-out;
2641
+ }
2642
+
2643
+ /* Enhanced Chat Entries */
2644
+ .chat-entry {
2645
+ display: flex;
2646
+ margin-bottom: 20px;
2647
+ align-items: flex-start;
2648
+ animation: fadeInUp 0.3s ease-out;
2649
+ }
2650
+
2651
+ @keyframes fadeInUp {
2652
+ from {
2653
+ opacity: 0;
2654
+ transform: translateY(20px);
2655
+ }
2656
+ to {
2657
+ opacity: 1;
2658
+ transform: translateY(0);
2659
+ }
2660
+ }
2661
+
2662
+ .chat-avatar {
2663
+ width: 44px;
2664
+ height: 44px;
2665
+ border-radius: 50%;
2666
+ margin-right: 12px;
2667
+ object-fit: cover;
2668
+ flex-shrink: 0;
2669
+ border: 2px solid rgba(116, 185, 255, 0.2);
2670
+ transition: all 0.3s ease;
2671
+ background-color: #ffffff;
2672
+ }
2673
+
2674
+ .chat-entry:hover .chat-avatar {
2675
+ border-color: rgba(116, 185, 255, 0.4);
2676
+ }
2677
+
2678
+ .message-content {
2679
+ display: flex;
2680
+ flex-direction: column;
2681
+ flex-grow: 1;
2682
+ min-width: 0;
2683
+ }
2684
+
2685
+ /* Enhanced Message Bubbles */
2686
+ .balloon {
2687
+ padding: 14px 16px;
2688
+ border-radius: 16px 16px 16px 4px;
2689
+ max-width: 85%;
2690
+ word-wrap: break-word;
2691
+ background: linear-gradient(135deg, rgba(116, 185, 255, 0.1), rgba(116, 185, 255, 0.05));
2692
+ border: 1px solid rgba(116, 185, 255, 0.2);
2693
+ transition: all 0.3s ease;
2694
+ position: relative;
2695
+ line-height: 1.4;
2696
+ font-size: 0.95rem;
2697
+ }
2698
+
2699
+ .balloon:hover {
2700
+ background: linear-gradient(135deg, rgba(116, 185, 255, 0.2), rgba(116, 185, 255, 0.1));
2701
+ border-color: rgba(116, 185, 255, 0.4);
2702
+ // transform: scale(1.01);
2703
+ transform: translateX(2px);
2704
+ cursor: pointer;
2705
+ }
2706
+
2707
+ .chat-entry.event-day .balloon {
2708
+ background: linear-gradient(135deg, rgba(255, 193, 7, 0.1), rgba(255, 193, 7, 0.05));
2709
+ border-color: rgba(255, 193, 7, 0.2);
2710
+ color: var(--text-primary);
2711
+ }
2712
+
2713
+ .chat-entry.event-day .balloon:hover {
2714
+ background: linear-gradient(135deg, rgba(255, 193, 7, 0.2), rgba(255, 193, 7, 0.1));
2715
+ border-color: rgba(255, 193, 7, 0.3);
2716
+ }
2717
+
2718
+ .chat-entry.event-night .balloon {
2719
+ background: linear-gradient(135deg, rgba(108, 92, 231, 0.1), rgba(108, 92, 231, 0.05));
2720
+ border-color: rgba(108, 92, 231, 0.2);
2721
+ }
2722
+
2723
+ .event-log-list li.now-playing .balloon {
2724
+ background-color: #fcf8e3; /* A light yellow */
2725
+ border-color: #f7d794;
2726
+ transition: background-color 0.3s ease;
2727
+ }
2728
+
2729
+ /* Enhanced System Messages */
2730
+ .msg-entry {
2731
+ border-left: 4px solid #f39c12;
2732
+ padding: 16px;
2733
+ margin: 16px 0;
2734
+ border-radius: 8px;
2735
+ background: linear-gradient(135deg, rgba(243, 156, 18, 0.1), rgba(243, 156, 18, 0.05));
2736
+ border: 1px solid rgba(243, 156, 18, 0.2);
2737
+ transition: all 0.3s ease;
2738
+ animation: fadeInUp 0.3s ease-out;
2739
+ }
2740
+
2741
+ .msg-entry:hover {
2742
+ background: linear-gradient(135deg, rgba(243, 156, 18, 0.15), rgba(243, 156, 18, 0.08));
2743
+ border-color: rgba(243, 156, 18, 0.3);
2744
+ }
2745
+
2746
+ .msg-entry.event-day {
2747
+ background: linear-gradient(135deg, rgba(255, 193, 7, 0.1), rgba(255, 193, 7, 0.05));
2748
+ border-color: rgba(255, 193, 7, 0.2);
2749
+ }
2750
+
2751
+ .msg-entry.event-night {
2752
+ background: linear-gradient(135deg, rgba(108, 92, 231, 0.1), rgba(108, 92, 231, 0.05));
2753
+ border-color: rgba(108, 92, 231, 0.2);
2754
+ }
2755
+
2756
+ .msg-entry.game-event {
2757
+ border-left-color: #e74c3c;
2758
+ background: linear-gradient(135deg, rgba(231, 76, 60, 0.1), rgba(231, 76, 60, 0.05));
2759
+ border-color: rgba(231, 76, 60, 0.2);
2760
+ }
2761
+
2762
+ .msg-entry.game-win {
2763
+ border-left-color: #2ecc71;
2764
+ background: linear-gradient(135deg, rgba(46, 204, 113, 0.1), rgba(46, 204, 113, 0.05));
2765
+ border-color: rgba(46, 204, 113, 0.2);
2766
+ line-height: 1.6;
2767
+ }
2768
+
2769
+ /* Enhanced Reasoning Text */
2770
+ .reasoning-text {
2771
+ font-size: 0.85rem;
2772
+ color: var(--text-muted);
2773
+ font-style: italic;
2774
+ margin-top: 8px;
2775
+ padding-left: 12px;
2776
+ border-left: 2px solid rgba(116, 185, 255, 0.3);
2777
+ line-height: 1.4;
2778
+ font-family: 'JetBrains Mono', monospace;
2779
+ display: none;
2780
+ }
2781
+ .reasoning-text.visible {
2782
+ display: block;
2783
+ }
2784
+ .reasoning-toggle {
2785
+ cursor: pointer;
2786
+ font-size: 1rem;
2787
+ margin-left: 5;
2788
+ opacity: 0.6;
2789
+ transition: all 0.3s ease;
2790
+ display: inline-flex;
2791
+ align-items: center;
2792
+ }
2793
+ .reasoning-toggle:hover {
2794
+ opacity: 1;
2795
+ }
2796
+ .msg-entry .reasoning-toggle {
2797
+ float: right;
2798
+ }
2799
+
2800
+ /* Enhanced Citations */
2801
+ #chat-log cite {
2802
+ font-style: normal;
2803
+ font-weight: 600;
2804
+ display: flex;
2805
+ align-items: center;
2806
+ font-size: 0.9rem;
2807
+ color: var(--text-primary);
2808
+ margin-bottom: 6px;
2809
+ gap: 8px;
2810
+ }
2811
+
2812
+ .cite-text-wrapper {
2813
+ display: flex;
2814
+ flex-direction: column;
2815
+ }
2816
+
2817
+ /* Enhanced Moderator Announcements */
2818
+ .moderator-announcement {
2819
+ margin: 16px 0;
2820
+ animation: fadeInUp 0.3s ease-out;
2821
+ }
2822
+
2823
+ .moderator-announcement-content {
2824
+ padding: 16px;
2825
+ border-radius: 12px;
2826
+ background: linear-gradient(135deg, rgba(46, 204, 113, 0.1), rgba(46, 204, 113, 0.05));
2827
+ border: 1px solid rgba(46, 204, 113, 0.2);
2828
+ border-left: 4px solid #2ecc71;
2829
+ color: var(--text-primary);
2830
+ line-height: 1.5;
2831
+ transition: all 0.3s ease;
2832
+ }
2833
+
2834
+ .moderator-announcement-content:hover {
2835
+ background: linear-gradient(135deg, rgba(46, 204, 113, 0.15), rgba(46, 204, 113, 0.08));
2836
+ border-color: rgba(46, 204, 113, 0.3);
2837
+ }
2838
+
2839
+ /* Enhanced Timestamps */
2840
+ .timestamp {
2841
+ font-size: 0.75rem;
2842
+ color: var(--text-muted);
2843
+ font-weight: 500;
2844
+ font-family: 'JetBrains Mono', monospace;
2845
+ background: rgba(116, 185, 255, 0.1);
2846
+ padding: 2px 6px;
2847
+ border-radius: 4px;
2848
+ margin-left: auto;
2849
+ }
2850
+
2851
+ /* Enhanced Player Capsules */
2852
+ .player-capsule {
2853
+ display: inline-flex;
2854
+ align-items: center;
2855
+ background: linear-gradient(135deg, rgba(116, 185, 255, 0.15), rgba(116, 185, 255, 0.08));
2856
+ border: 1px solid rgba(116, 185, 255, 0.2);
2857
+ border-radius: 16px;
2858
+ padding: 2px 10px 2px 2px;
2859
+ font-size: 0.875rem;
2860
+ font-weight: 500;
2861
+ margin: 0 2px;
2862
+ vertical-align: middle;
2863
+ transition: all 0.3s ease;
2864
+ }
2865
+
2866
+ .player-capsule:hover {
2867
+ background: linear-gradient(135deg, rgba(116, 185, 255, 0.2), rgba(116, 185, 255, 0.1));
2868
+ border-color: rgba(116, 185, 255, 0.3);
2869
+ }
2870
+
2871
+ .capsule-avatar {
2872
+ width: 20px;
2873
+ height: 20px;
2874
+ border-radius: 50%;
2875
+ margin-right: 6px;
2876
+ object-fit: cover;
2877
+ border: 1px solid rgba(255, 255, 255, 0.2);
2878
+ background-color: #ffffff;
2879
+ }
2880
+
2881
+ .capsule-display-name {
2882
+ font-size: 0.9em;
2883
+ color: #888;
2884
+ margin-left: 5px;
2885
+ }
2886
+
2887
+ /* Enhanced TTS Button */
2888
+ .tts-button {
2889
+ cursor: pointer;
2890
+ font-size: 1.1rem;
2891
+ margin-left: 12px;
2892
+ padding: 4px;
2893
+ border-radius: 50%;
2894
+ transition: all 0.3s ease;
2895
+ opacity: 0.6;
2896
+ }
2897
+
2898
+ .tts-button:hover {
2899
+ opacity: 1;
2900
+ background: rgba(116, 185, 255, 0.1);
2901
+ transform: scale(1.1);
2902
+ }
2903
+
2904
+ /* Enhanced Audio Controls */
2905
+ .audio-controls {
2906
+ padding: 16px 0;
2907
+ border-top: 1px solid rgba(116, 185, 255, 0.2);
2908
+ margin-top: 16px;
2909
+ background: rgba(255, 255, 255, 0.02);
2910
+ border-radius: 8px;
2911
+ padding: 16px;
2912
+ }
2913
+
2914
+ .audio-controls label {
2915
+ display: block;
2916
+ margin-bottom: 8px;
2917
+ font-size: 0.875rem;
2918
+ font-weight: 500;
2919
+ color: var(--text-secondary);
2920
+ }
2921
+
2922
+ .audio-controls input[type="range"] {
2923
+ width: 100%;
2924
+ height: 6px;
2925
+ border-radius: 3px;
2926
+ background: rgba(116, 185, 255, 0.2);
2927
+ outline: none;
2928
+ -webkit-appearance: none;
2929
+ }
2930
+
2931
+ .audio-controls input[type="range"]::-webkit-slider-thumb {
2932
+ -webkit-appearance: none;
2933
+ width: 16px;
2934
+ height: 16px;
2935
+ border-radius: 50%;
2936
+ background: #74b9ff;
2937
+ cursor: pointer;
2938
+ border: 2px solid #ffffff;
2939
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
2940
+ }
2941
+
2942
+ #pause-audio {
2943
+ background-color: rgba(116, 185, 255, 0.1);
2944
+ border: 1px solid rgba(116, 185, 255, 0.3);
2945
+ border-radius: 50%;
2946
+ width: 36px;
2947
+ height: 36px;
2948
+ cursor: pointer;
2949
+ padding: 0;
2950
+ background-size: 16px;
2951
+ background-repeat: no-repeat;
2952
+ background-position: center;
2953
+ transition: all 0.3s ease;
2954
+ filter: none;
2955
+ }
2956
+
2957
+ #pause-audio:hover {
2958
+ background-color: rgba(116, 185, 255, 0.2);
2959
+ border-color: rgba(116, 185, 255, 0.5);
2960
+ transform: scale(1.1);
2961
+ }
2962
+
2963
+ #pause-audio.paused {
2964
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiM3NGI5ZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWdvbiBwb2ludHM9IjYgMyAyMCAxMiA2IDIxIi8+PC9zdmc+');
2965
+ }
2966
+
2967
+ #pause-audio.playing {
2968
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiM3NGI5ZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cmVjdCB4PSI2IiB5PSI0IiB3aWR0aD0iNCIgaGVpZ2h0PSIxNiIgcng9IjEiLz48cmVjdCB4PSIxNCIgeT0iNCIgd2lkdGg9IjQiIGhlaWdodD0iMTYiIHJ4PSIxIi8+PC9zdmc+');
2969
+ }
2970
+
2971
+ /* Message text formatting */
2972
+ .msg-text {
2973
+ line-height: 1.5;
2974
+ font-size: 0.95rem;
2975
+ }
2976
+
2977
+ .msg-text br {
2978
+ display: block;
2979
+ margin-bottom: 0.5em;
2980
+ content: "";
2981
+ }
2982
+
2983
+ /* Smooth scrolling */
2984
+ * {
2985
+ scrollbar-width: thin;
2986
+ scrollbar-color: rgba(116, 185, 255, 0.3) transparent;
2987
+ }
2988
+ `;
2989
+
2990
+ // --- TTS Management ---
2991
+ const audioMap = window.AUDIO_MAP || {};
2992
+
2993
+ if (!window.kaggleWerewolf) {
2994
+ window.kaggleWerewolf = {
2995
+ audioQueue: [],
2996
+ isAudioPlaying: false,
2997
+ isAudioEnabled: false,
2998
+ isPaused: false,
2999
+ lastPlayedStep: parseInt(sessionStorage.getItem('ww_lastPlayedStep') || '-1', 10),
3000
+ audioPlayer: new Audio(),
3001
+ playbackRate: 1.6,
3002
+ allEvents: null,
3003
+ audioContextActivated: false,
3004
+ };
3005
+ }
3006
+ const audioState = window.kaggleWerewolf;
3007
+
3008
+ if (audioState.hasAudioTracks === undefined) {
3009
+ audioState.hasAudioTracks = Object.keys(audioMap).length > 0;
3010
+ }
3011
+
3012
+ function setPlaybackRate(rate) {
3013
+ audioState.playbackRate = rate;
3014
+ if (audioState.isAudioPlaying) {
3015
+ audioState.audioPlayer.playbackRate = rate;
3016
+ }
3017
+ }
3018
+
3019
+ function speak(allEventsIndex) {
3020
+ if (allEventsIndex === undefined) return;
3021
+
3022
+ // 1. Find the corresponding display step for the slider.
3023
+ const displayStep = window.werewolfGamePlayer.allEventsIndexToDisplayStep[allEventsIndex];
3024
+
3025
+ // 2. Jump the slider.
3026
+ // This will automatically trigger our setStep wrapper, which calls
3027
+ // stopAndClearAudio() and sets audioState.isPaused = true.
3028
+ if (displayStep !== undefined && window.kaggle && window.kaggle.setStep) {
3029
+ // window.kaggle.setStep(displayStep);
3030
+ context.__mainContext.setStep(displayStep);
3031
+ }
3032
+
3033
+ // 3. Start continuous playback from that point.
3034
+ // playAudioFrom() will see we are paused, load the new queue,
3035
+ // and immediately start playing.
3036
+ playAudioFrom(allEventsIndex);
3037
+ }
3038
+
3039
+ // --- Helper Functions ---
3040
+ function formatTimestamp(isoString) {
3041
+ if (!isoString) return '';
3042
+ try {
3043
+ return new Date(isoString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
3044
+ } catch (e) {
3045
+ return '';
3046
+ }
3047
+ }
3048
+
3049
+ /**
3050
+ * Creates a memoized function to replace player IDs with HTML capsules.
3051
+ * This function pre-computes and caches sorted player data for efficiency.
3052
+ * @param {Map<string, object>} playerMap - A map from player ID to player object.
3053
+ * @returns {function(string): string} A function that takes text and returns it with player IDs replaced.
3054
+ */
3055
+ function createPlayerIdReplacer(playerMap) {
3056
+ // Cache for already processed text strings (memoization)
3057
+ const textCache = new Map();
3058
+
3059
+ // --- Pre-computation Cache ---
3060
+ const sortedPlayerReplacements = [...playerMap.keys()]
3061
+ .sort((a, b) => b.length - a.length) // Sort by length to match longest names first
3062
+ .map(playerId => {
3063
+ const player = playerMap.get(playerId);
3064
+ if (!player) return null;
3065
+
3066
+ return {
3067
+ capsule: createPlayerCapsule(player),
3068
+ // IMPROVEMENT: This new regex correctly handles both internal periods in names (e.g., 'gemini-1.5-pro')
3069
+ // and sentence-ending periods (e.g., '... says Kai.').
3070
+ // Breakdown:
3071
+ // 1. (^|[^\w.-]) - The prefix boundary must not be a name character. This is unchanged.
3072
+ // 2. (PLAYER_ID) - The player's name.
3073
+ // 3. (\.?) - Optionally captures a single trailing period.
3074
+ // 4. (?![-\w]) - A negative lookahead asserts that the name is not followed by another name character (a-z, 0-9, _, -).
3075
+ // This is the key part that allows a trailing period to be treated as a boundary.
3076
+ regex: new RegExp(`(^|[^\\w.-])(${playerId.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')})(\\.?)(?![\\w-])`, 'g')
3077
+ };
3078
+ }).filter(Boolean);
3079
+
3080
+ return function (text) {
3081
+ if (!text) return '';
3082
+ if (textCache.has(text)) {
3083
+ return textCache.get(text);
3084
+ }
3085
+
3086
+ let newText = text;
3087
+ for (const replacement of sortedPlayerReplacements) {
3088
+ // The replacement string now uses $3 to append the optionally captured period after the capsule.
3089
+ newText = newText.replace(replacement.regex, `$1${replacement.capsule}$3`);
3090
+ }
3091
+
3092
+ textCache.set(text, newText);
3093
+ return newText;
3094
+ };
3095
+ }
3096
+
3097
+ function createPlayerCapsule(player) {
3098
+ if (!player) return '';
3099
+ let display_name_elem = (player.display_name && (player.name !== player.display_name)) ? `<span class="capsule-display-name">${player.display_name}</span>` : "";
3100
+ return `<span class="player-capsule" title="${player.name}">
3101
+ <img src="${player.thumbnail}" class="capsule-avatar" alt="${player.name}">
3102
+ <span class="capsule-name">${player.name}</span>${display_name_elem}
3103
+ </span>`;
3104
+ }
3105
+
3106
+ function replacePlayerIdsWithCapsules(text, playerIds, playerMap) {
3107
+ if (!text) return '';
3108
+ if (!playerIds || playerIds.length === 0) {
3109
+ return text;
3110
+ }
3111
+ let newText = text;
3112
+ const sortedPlayerIds = [...playerIds].sort((a, b) => b.length - a.length);
3113
+
3114
+ sortedPlayerIds.forEach(playerId => {
3115
+ const player = playerMap.get(playerId);
3116
+ if (player) {
3117
+ const capsule = createPlayerCapsule(player);
3118
+ const escapedPlayerId = playerId.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
3119
+
3120
+ // Using the same improved regex as in the factory function.
3121
+ const regex = new RegExp(`(^|[^\\w.-])(${escapedPlayerId})(\\.?)(?![\\w-])`, 'g');
3122
+
3123
+ // The replacement correctly places the captured prefix ($1) and optional period ($3) around the capsule.
3124
+ newText = newText.replace(regex, `$1${capsule}$3`);
3125
+ }
3126
+ });
3127
+ return newText;
3128
+ }
3129
+
3130
+ function replacePlayerIdsWithBold(text, playerIds) {
3131
+ if (!text) return '';
3132
+ if (!playerIds || playerIds.length === 0) {
3133
+ return text;
3134
+ }
3135
+ let newText = text;
3136
+ const sortedPlayerIds = [...playerIds].sort((a, b) => b.length - a.length);
3137
+
3138
+ sortedPlayerIds.forEach(playerId => {
3139
+ const regex = new RegExp(`\b${playerId.replace(/[-\/\\^$*+?.()|[\\]{}/g, '\\$&')}\b`, 'g');
3140
+ newText = newText.replace(regex, `<strong>${playerId}</strong>`);
3141
+ });
3142
+ return newText;
3143
+ }
3144
+
3145
+
3146
+ function getThreatColor(threatLevel) {
3147
+ const value = Math.max(0, Math.min(1, threatLevel));
3148
+ const hue = 120 * (1 - value);
3149
+ return `hsl(${hue}, 100%, 50%)`;
3150
+ }
3151
+
3152
+ function updatePlayerList(container, gameState, actingPlayerName) {
3153
+ // Get or create header
3154
+ let header = container.querySelector('h1');
3155
+ if (!header) {
3156
+ header = document.createElement('h1');
3157
+ // Create a span for the title to sit next to the button
3158
+ const titleSpan = document.createElement('span');
3159
+ titleSpan.textContent = 'Players';
3160
+ header.appendChild(titleSpan);
3161
+
3162
+ // Create the reset button
3163
+ const resetButton = document.createElement('button');
3164
+ resetButton.id = 'reset-view-btn';
3165
+ resetButton.className = 'reset-view-btn';
3166
+ resetButton.title = 'Reset Camera View';
3167
+ resetButton.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v6h6"/><path d="M21 12A9 9 0 0 0 6 5.3L3 8"/><path d="M21 22v-6h-6"/><path d="M3 12a9 9 0 0 0 15 6.7l3-2.7"/></svg>`;
3168
+
3169
+ header.appendChild(resetButton);
3170
+ container.appendChild(header);
3171
+
3172
+ // Add the click listener only once, when the button is created
3173
+ resetButton.onclick = () => {
3174
+ if (threeState && threeState.demo) {
3175
+ threeState.demo.resetCameraView();
3176
+ }
3177
+ };
3178
+ }
3179
+
3180
+ // Get or create list container
3181
+ let listContainer = container.querySelector('#player-list-container');
3182
+ if (!listContainer) {
3183
+ listContainer = document.createElement('div');
3184
+ listContainer.id = 'player-list-container';
3185
+ container.appendChild(listContainer);
3186
+ }
3187
+
3188
+ // Get or create player list
3189
+ let playerUl = listContainer.querySelector('#player-list');
3190
+ if (!playerUl) {
3191
+ playerUl = document.createElement('ul');
3192
+ playerUl.id = 'player-list';
3193
+ listContainer.appendChild(playerUl);
3194
+ }
3195
+
3196
+ // Update player cards
3197
+ gameState.players.forEach((player, index) => {
3198
+ let li = playerUl.children[index];
3199
+ if (!li) {
3200
+ li = document.createElement('li');
3201
+ playerUl.appendChild(li);
3202
+ }
3203
+
3204
+ // Add the onclick handler of player's first person perspective
3205
+ // This will call the focus function on the Three.js demo instance
3206
+ li.onclick = () => {
3207
+ if (threeState && threeState.demo) {
3208
+ // Get the current widths of the UI panels
3209
+ const leftPanel = parent.querySelector('.left-panel');
3210
+ const rightPanel = parent.querySelector('.right-panel');
3211
+ const leftPanelWidth = leftPanel ? leftPanel.offsetWidth : 0;
3212
+ const rightPanelWidth = rightPanel ? rightPanel.offsetWidth : 0;
3213
+
3214
+ // Pass the panel widths to the focus function
3215
+ threeState.demo.focusOnPlayer(player.name, leftPanelWidth, rightPanelWidth);
3216
+ }
3217
+ };
3218
+
3219
+ // Update player card classes
3220
+ li.className = 'player-card';
3221
+ if (!player.is_alive) li.classList.add('dead');
3222
+ if (player.name === actingPlayerName) li.classList.add('active');
3223
+
3224
+ let roleDisplay = player.role;
3225
+ if (player.role === 'Werewolf') {
3226
+ roleDisplay = `&#x1F43A; ${player.role}`;
3227
+ } else if (player.role === 'Doctor') {
3228
+ roleDisplay = `&#x1FA7A; ${player.role}`;
3229
+ } else if (player.role === 'Seer') {
3230
+ roleDisplay = `&#x1F52E; ${player.role}`;
3231
+ } else if (player.role === 'Villager') {
3232
+ roleDisplay = `&#x1F9D1; ${player.role}`;
3233
+ }
3234
+
3235
+ const roleText = player.role !== 'Unknown' ? `Role: ${roleDisplay}` : 'Role: Unknown';
3236
+
3237
+ // Update content
3238
+ let player_name_element = `<div class="player-name" title="${player.name}">${player.name}</div>`
3239
+ if (player.display_name && player.display_name !== player.name) {
3240
+ player_name_element = `<div class="player-name" title="${player.name}">
3241
+ ${player.name}<span class="display-name">${player.display_name}</span>
3242
+ </div>`
3243
+ }
3244
+
3245
+ li.innerHTML = `
3246
+ <div class="avatar-container">
3247
+ <img src="${player.thumbnail}" alt="${player.name}" class="avatar">
3248
+ </div>
3249
+ <div class="player-info">
3250
+ ${player_name_element}
3251
+ <div class="player-role">${roleText}</div>
3252
+ </div>
3253
+ <div class="threat-indicator"></div>
3254
+ `;
3255
+
3256
+ // Update threat indicator
3257
+ const indicator = li.querySelector('.threat-indicator');
3258
+ if (indicator && player.is_alive) {
3259
+ const threatLevel = gameState.playerThreatLevels.get(player.name) || 0;
3260
+ indicator.style.backgroundColor = getThreatColor(threatLevel);
3261
+ } else if (indicator) {
3262
+ indicator.style.backgroundColor = 'transparent';
3263
+ }
3264
+ });
3265
+
3266
+ // Remove excess player cards
3267
+ while (playerUl.children.length > gameState.players.length) {
3268
+ playerUl.removeChild(playerUl.lastChild);
3269
+ }
3270
+
3271
+
3272
+ }
3273
+
3274
+ function updateEventLog(container, gameState, playerMap) {
3275
+ const audioState = window.kaggleWerewolf;
3276
+ const audioToggleDisabled = !audioState.hasAudioTracks;
3277
+ const audioToggleEnabled = audioState.isAudioEnabled && !audioToggleDisabled;
3278
+ const audioToggleTitle = audioToggleDisabled ? 'Audio Not Available' : 'Toggle Audio';
3279
+ const audioToggleIcon = audioToggleEnabled ? '&#x1F50A;' : '&#x1F507;'; // Speaker vs Muted
3280
+ const audioToggleClasses = `audio-toggle-btn ${audioToggleDisabled ? 'disabled' : ''} ${audioToggleEnabled ? 'enabled' : ''}`;
3281
+
3282
+ container.innerHTML = `
3283
+ <h1>
3284
+ <span>Events</span>
3285
+ <div id="header-controls" style="display: flex; align-items: center; gap: 15px;">
3286
+ <button id="global-reasoning-toggle" title="Toggle All Reasoning">
3287
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
3288
+ </button>
3289
+ <div id="speed-control-container" style="display: flex; align-items: center; gap: 5px;">
3290
+ <input type="range" id="playback-speed" min="0.5" max="2.5" step="0.1" value="${audioState.playbackRate}" style="width: 70px;">
3291
+ <span id="speed-label" style="font-size: 12px; min-width: 30px;">${audioState.playbackRate.toFixed(1)}x</span>
3292
+ </div>
3293
+ <button id="global-audio-toggle" title="${audioToggleTitle}" class="${audioToggleClasses}">
3294
+ ${audioToggleIcon}
3295
+ </button>
3296
+ </div>
3297
+ </h1>
3298
+ `;
3299
+
3300
+ const logUl = document.createElement('ul');
3301
+ logUl.id = 'chat-log';
3302
+
3303
+ const logEntries = gameState.eventLog;
3304
+
3305
+ if (logEntries.length === 0) {
3306
+ const li = document.createElement('li');
3307
+ li.className = 'msg-entry';
3308
+ li.innerHTML = `<cite>System</cite><div>The game is about to begin...</div>`;
3309
+ logUl.appendChild(li);
3310
+ } else {
3311
+ logEntries.forEach( (entry, entryIndex) => {
3312
+ const li = document.createElement('li');
3313
+ li.dataset.allEventsIndex = entry.allEventsIndex;
3314
+ let reasoningHtml = '';
3315
+ let reasoningToggleHtml = '';
3316
+ if (entry.reasoning) {
3317
+ const reasoningId = `reasoning-${window.werewolfGamePlayer.reasoningCounter++}`;
3318
+ reasoningHtml = `<div class="reasoning-text" id="${reasoningId}">"${entry.reasoning}"</div>`;
3319
+ reasoningToggleHtml = `<span class="reasoning-toggle" title="Show/Hide Reasoning" onclick="event.stopPropagation(); document.getElementById('${reasoningId}').classList.toggle('visible')">
3320
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-eye"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
3321
+ </span>`;
3322
+ }
3323
+
3324
+ let phase = (entry.phase || 'Day').toUpperCase();
3325
+ const entryType = entry.type;
3326
+ const systemText = (entry.text || '').toLowerCase();
3327
+
3328
+ const phaseClass = `event-${phase.toLowerCase()}`;
3329
+
3330
+ let phaseEmoji = phase;
3331
+ if (phase === 'DAY') {
3332
+ phaseEmoji = '&#x2600;&#xFE0F;';
3333
+ } else if (phase === 'NIGHT') {
3334
+ phaseEmoji = '&#x1F319;';
3335
+ }
3336
+
3337
+ const dayPhaseString = entry.day !== Infinity ? `[${phaseEmoji} ${entry.day}]` : '';
3338
+ const timestampHtml = `<span class="timestamp">${dayPhaseString} ${formatTimestamp(entry.timestamp)}</span>`;
3339
+
3340
+ switch (entry.type) {
3341
+ case 'chat':
3342
+ const speaker = playerMap.get(entry.speaker);
3343
+ if (!speaker) return;
3344
+ const messageText = window.werewolfGamePlayer.playerIdReplacer(entry.message);
3345
+ li.className = `chat-entry event-day`;
3346
+ li.innerHTML = `
3347
+ <img src="${speaker.thumbnail}" alt="${speaker.name}" class="chat-avatar">
3348
+ <div class="message-content">
3349
+ <cite>
3350
+ <span> <span>${speaker.name}</span>
3351
+ ${speaker.display_name && speaker.name !== speaker.display_name ? `<span class="display-name">${speaker.display_name}</span>` : ''}
3352
+ </span> ${reasoningToggleHtml}
3353
+ ${timestampHtml}
3354
+ </cite>
3355
+ <div class="balloon">
3356
+ <div class="balloon-text">
3357
+ <quote>${messageText}</quote>
3358
+ </div>
3359
+ ${reasoningHtml}
3360
+ </div>
3361
+ </div>
3362
+ `;
3363
+ const balloon = li.querySelector('.balloon');
3364
+ if (balloon) {
3365
+ balloon.onclick = (e) => {
3366
+ e.stopPropagation();
3367
+ // This will either play (if audio is enabled)
3368
+ // or queue the request (if audio is disabled)
3369
+ speak(entry.allEventsIndex);
3370
+ };
3371
+ }
3372
+ break;
3373
+ case 'seer_inspection':
3374
+ const seerInspector = playerMap.get(entry.actor_id);
3375
+ if (!seerInspector) return;
3376
+ const seerTargetCap = createPlayerCapsule(playerMap.get(entry.target));
3377
+ const seerCap = createPlayerCapsule(playerMap.get(entry.actor_id));
3378
+ li.className = `chat-entry event-night`;
3379
+ li.innerHTML = `
3380
+ <img src="${seerInspector.thumbnail}" alt="${seerInspector.name}" class="chat-avatar">
3381
+ <div class="message-content">
3382
+ <cite>Seer Secret Inspect ${reasoningToggleHtml} ${timestampHtml}</cite>
3383
+ <div class="balloon">
3384
+ <div class="balloon-text">${seerCap} chose to inspect ${seerTargetCap}'s role.</div>
3385
+ ${reasoningHtml}
3386
+ </div>
3387
+ </div>
3388
+ `;
3389
+ const seer_balloon = li.querySelector('.balloon');
3390
+ if (seer_balloon) {
3391
+ seer_balloon.onclick = (e) => {
3392
+ e.stopPropagation();
3393
+ // This will either play (if audio is enabled)
3394
+ // or queue the request (if audio is disabled)
3395
+ speak(entry.allEventsIndex);
3396
+ };
3397
+ }
3398
+ break;
3399
+ case 'seer_inspection_result':
3400
+ const seerResultViewer = playerMap.get(entry.seer);
3401
+ if (!seerResultViewer) return;
3402
+ const seerCap_ = createPlayerCapsule(playerMap.get(entry.seer));
3403
+ const seerResultTargetCap = createPlayerCapsule(playerMap.get(entry.target));
3404
+ const resultString = entry.role
3405
+ ? `role is a <strong>${entry.role}</strong>`
3406
+ : `team is <strong>${entry.team}</strong>`;
3407
+
3408
+ li.className = `chat-entry ${phaseClass}`;
3409
+ li.innerHTML = `
3410
+ <img src="${seerResultViewer.thumbnail}" alt="${seerResultViewer.name}" class="chat-avatar">
3411
+ <div class="message-content">
3412
+ <cite>Seer Inspect Result ${timestampHtml}</cite>
3413
+ <div class="balloon">
3414
+ <div class="balloon-text">${seerCap_} saw ${seerResultTargetCap}'s ${resultString}.</div>
3415
+ </div>
3416
+ </div>
3417
+ `;
3418
+ const seer_balloon_ = li.querySelector('.balloon');
3419
+ if (seer_balloon_) {
3420
+ seer_balloon_.onclick = (e) => {
3421
+ e.stopPropagation();
3422
+ // This will either play (if audio is enabled)
3423
+ // or queue the request (if audio is disabled)
3424
+ speak(entry.allEventsIndex);
3425
+ };
3426
+ }
3427
+ break;
3428
+ case 'doctor_heal_action':
3429
+ const doctor = playerMap.get(entry.actor_id);
3430
+ if (!doctor) return;
3431
+ const docTargetCap = createPlayerCapsule(playerMap.get(entry.target));
3432
+ const docCap = createPlayerCapsule(playerMap.get(entry.actor_id));
3433
+ li.className = `chat-entry event-night`;
3434
+ li.innerHTML = `
3435
+ <img src="${doctor.thumbnail}" alt="${doctor.name}" class="chat-avatar">
3436
+ <div class="message-content">
3437
+ <cite>Doctor Secret Heal ${reasoningToggleHtml} ${timestampHtml}</cite>
3438
+ <div class="balloon">
3439
+ <div class="balloon-text">${docCap} chose to heal ${docTargetCap}.</div>
3440
+ ${reasoningHtml}
3441
+ </div>
3442
+ </div>
3443
+ `;
3444
+ const dr_balloon = li.querySelector('.balloon');
3445
+ if (dr_balloon) {
3446
+ dr_balloon.onclick = (e) => {
3447
+ e.stopPropagation();
3448
+ // This will either play (if audio is enabled)
3449
+ // or queue the request (if audio is disabled)
3450
+ speak(entry.allEventsIndex);
3451
+ };
3452
+ }
3453
+ break;
3454
+ case 'system':
3455
+ if (entry.text && entry.text.includes('has begun')) return;
3456
+
3457
+ let systemText = entry.text;
3458
+
3459
+ // This enhanced regex captures the list content (group 1) and any optional
3460
+ // trailing punctuation like a period or comma (group 2).
3461
+ const listRegex = /\[(.*?)\](\s*[.,?!])?/g;
3462
+
3463
+ systemText = systemText.replace(listRegex, (match, listContent, punctuation) => {
3464
+ // Clean the list content as before
3465
+ const cleanedContent = listContent.replace(/'/g, "").replace(/, /g, " ").trim();
3466
+
3467
+ // If punctuation was captured, return the content with a space before the punctuation
3468
+ if (punctuation) {
3469
+ return cleanedContent + " " + punctuation.trim();
3470
+ }
3471
+
3472
+ // Otherwise, just return the cleaned content
3473
+ return cleanedContent;
3474
+ });
3475
+
3476
+ // NOW, run the efficient replacer on the cleaned-up string.
3477
+ const finalSystemText = window.werewolfGamePlayer.playerIdReplacer(systemText);
3478
+
3479
+ li.className = `moderator-announcement`;
3480
+ li.innerHTML = `
3481
+ <cite>Moderator
3482
+ ${timestampHtml}</cite>
3483
+ <div class="moderator-announcement-content ${phaseClass}">
3484
+ <div class="msg-text">${finalSystemText.replace(/\n/g, '<br>')}</div>
3485
+ </div>
3486
+ `;
3487
+
3488
+ const content = li.querySelector('.moderator-announcement-content');
3489
+ if (content) {
3490
+ content.style.cursor = 'pointer'; // Optional: make it look clickable
3491
+ content.onclick = (e) => {
3492
+ e.stopPropagation();
3493
+ speak(entry.allEventsIndex);
3494
+ };
3495
+ }
3496
+ break;
3497
+ case 'exile':
3498
+ const exiledPlayerCap = createPlayerCapsule(playerMap.get(entry.name));
3499
+ li.className = `msg-entry game-event event-day`;
3500
+ let role_text = (entry.role) ? ` (${entry.role})` : "";
3501
+ li.innerHTML = `<cite>Exile ${timestampHtml}</cite><div class="msg-text">${exiledPlayerCap}${role_text} was exiled by vote.</div>`;
3502
+ li.style.cursor = 'pointer';
3503
+ li.onclick = (e) => {
3504
+ e.stopPropagation();
3505
+ speak(entry.allEventsIndex);
3506
+ };
3507
+ break;
3508
+ case 'elimination':
3509
+ const elimPlayerCap = createPlayerCapsule(playerMap.get(entry.name));
3510
+ li.className = `msg-entry game-event event-night`;
3511
+ let elim_role_text = (entry.role) ? ` Their role was a ${entry.role}.` : "";
3512
+ li.innerHTML = `<cite>Elimination ${timestampHtml}</cite><div class="msg-text">${elimPlayerCap} was eliminated.${elim_role_text}</div>`;
3513
+ li.style.cursor = 'pointer';
3514
+ li.onclick = (e) => {
3515
+ e.stopPropagation();
3516
+ speak(entry.allEventsIndex);
3517
+ };
3518
+ break;
3519
+ case 'save':
3520
+ const savedPlayerCap = createPlayerCapsule(playerMap.get(entry.saved_player));
3521
+ li.className = `msg-entry event-night`;
3522
+ li.innerHTML = `<cite>Doctor Save ${timestampHtml}</cite><div class="msg-text">Player ${savedPlayerCap} was attacked but saved by a Doctor!</div>`;
3523
+ li.style.cursor = 'pointer';
3524
+ li.onclick = (e) => {
3525
+ e.stopPropagation();
3526
+ speak(entry.allEventsIndex);
3527
+ };
3528
+ break;
3529
+ case 'vote':
3530
+ const voter = playerMap.get(entry.actor_id);
3531
+ if (!voter) return;
3532
+ const voterCap = createPlayerCapsule(playerMap.get(entry.actor_id));
3533
+ const voteTargetCap = createPlayerCapsule(playerMap.get(entry.target));
3534
+ li.className = `chat-entry event-day`;
3535
+ li.innerHTML = `
3536
+ <img src="${voter.thumbnail}" alt="${voter.name}" class="chat-avatar">
3537
+ <div class="message-content">
3538
+ <cite>
3539
+ <span> <span>${voter.name}</span>
3540
+ ${voter.display_name && voter.name !== voter.display_name ? `<span class="display-name">${voter.display_name}</span>` : ''}
3541
+ </span> ${reasoningToggleHtml}
3542
+ ${timestampHtml}
3543
+ </cite>
3544
+ <div class="balloon">
3545
+ <div class="balloon-text">${voterCap} votes to exile ${voteTargetCap}.</div>
3546
+ ${reasoningHtml}
3547
+ </div>
3548
+ </div>
3549
+ `;
3550
+ const vote_balloon = li.querySelector('.balloon');
3551
+ if (vote_balloon) {
3552
+ vote_balloon.onclick = (e) => {
3553
+ e.stopPropagation();
3554
+ speak(entry.allEventsIndex);
3555
+ };
3556
+ }
3557
+ break;
3558
+ case 'timeout':
3559
+ const to_voter = playerMap.get(entry.actor_id);
3560
+ if (!to_voter) return;
3561
+ const to_voterCap = createPlayerCapsule(playerMap.get(entry.actor_id));
3562
+ li.className = `chat-entry event-day`;
3563
+ li.innerHTML = `
3564
+ <img src="${to_voter.thumbnail}" alt="${to_voter.name}" class="chat-avatar">
3565
+ <div class="message-content">
3566
+ <cite>${to_voter.name} ${reasoningToggleHtml} ${timestampHtml}</cite>
3567
+ <div class="balloon">
3568
+ <div class="balloon-text">${to_voterCap} timed out and abstained from voting.</div>
3569
+ ${reasoningHtml}
3570
+ </div>
3571
+ </div>
3572
+ `;
3573
+ break;
3574
+ case 'night_vote':
3575
+ const nightVoter = playerMap.get(entry.actor_id);
3576
+ if (!nightVoter) return;
3577
+ const nightVoterCap = createPlayerCapsule(playerMap.get(entry.actor_id));
3578
+ const nightVoteTargetCap = createPlayerCapsule(playerMap.get(entry.target));
3579
+ li.className = `chat-entry event-night`;
3580
+ li.innerHTML = `
3581
+ <img src="${nightVoter.thumbnail}" alt="${nightVoter.name}" class="chat-avatar">
3582
+ <div class="message-content">
3583
+ <cite>Werewolf Secret Vote ${reasoningToggleHtml} ${timestampHtml}</cite>
3584
+ <div class="balloon">
3585
+ <div class="balloon-text">${nightVoterCap} votes to eliminate ${nightVoteTargetCap}.</div>
3586
+ ${reasoningHtml}
3587
+ </div>
3588
+ </div>
3589
+ `;
3590
+ const nvote_balloon = li.querySelector('.balloon');
3591
+ if (nvote_balloon) {
3592
+ nvote_balloon.onclick = (e) => {
3593
+ e.stopPropagation();
3594
+ speak(entry.allEventsIndex);
3595
+ };
3596
+ }
3597
+ break;
3598
+ case 'game_over':
3599
+ const winnersText = entry.winners.map(p => createPlayerCapsule(playerMap.get(p))).join(' ');
3600
+ const losersText = entry.losers.map(p => createPlayerCapsule(playerMap.get(p))).join(' ');
3601
+ li.className = `msg-entry game-win ${phaseClass}`;
3602
+ li.innerHTML = `
3603
+ <cite>Game Over ${timestampHtml}</cite>
3604
+ <div class="msg-text">
3605
+ <div>The <strong>${entry.winner}</strong> team has won!</div><br>
3606
+ <div><strong>Winning Team:</strong> ${winnersText}</div>
3607
+ <div><strong>Losing Team:</strong> ${losersText}</div>
3608
+ </div>
3609
+ `;
3610
+ li.style.cursor = 'pointer';
3611
+ li.onclick = (e) => {
3612
+ e.stopPropagation();
3613
+ speak(entry.allEventsIndex);
3614
+ };
3615
+ break;
3616
+ }
3617
+ if (li.innerHTML) logUl.appendChild(li);
3618
+ });
3619
+ }
3620
+
3621
+ container.appendChild(logUl);
3622
+ logUl.scrollTop = logUl.scrollHeight;
3623
+
3624
+ const globalToggle = container.querySelector('#global-reasoning-toggle');
3625
+ if (globalToggle) {
3626
+ globalToggle.addEventListener('click', (event) => {
3627
+ event.stopPropagation();
3628
+ const reasoningTexts = logUl.querySelectorAll('.reasoning-text');
3629
+ if (reasoningTexts.length === 0) return;
3630
+
3631
+ // Determine if we should show or hide all. If any are visible, we hide all. Otherwise, show all.
3632
+ const shouldShow = ![...reasoningTexts].some(el => el.classList.contains('visible'));
3633
+
3634
+ reasoningTexts.forEach(el => {
3635
+ el.classList.toggle('visible', shouldShow);
3636
+ });
3637
+ });
3638
+ }
3639
+
3640
+ const globalAudioToggle = container.querySelector('#global-audio-toggle');
3641
+ if (globalAudioToggle) {
3642
+ globalAudioToggle.addEventListener('click', (event) => {
3643
+ event.stopPropagation();
3644
+ if (globalAudioToggle.classList.contains('disabled')) return;
3645
+
3646
+ const audioState = window.kaggleWerewolf; // Get the state
3647
+ const wasEnabled = audioState.isAudioEnabled;
3648
+
3649
+ if (wasEnabled) {
3650
+ // --- DISABLING ---
3651
+ audioState.isAudioEnabled = false;
3652
+ globalAudioToggle.classList.remove('enabled');
3653
+ globalAudioToggle.innerHTML = '&#x1F507;'; // Muted icon
3654
+
3655
+ stopAndClearAudio(); // Stop current playback
3656
+ audioState.isPaused = true; // Ensure it stays paused
3657
+
3658
+ // Update left-panel pause button
3659
+ } else {
3660
+ // --- ENABLING ---
3661
+ audioState.isAudioEnabled = true;
3662
+ globalAudioToggle.classList.add('enabled');
3663
+ globalAudioToggle.innerHTML = '&#x1F50A;'; // Speaker icon
3664
+
3665
+ // Activate audio context if this is the very first time
3666
+ if (!audioState.audioContextActivated) {
3667
+ const audio = new Audio('data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA');
3668
+ audio.play().catch(e => console.warn("Audio context activation failed:", e));
3669
+ audioState.audioContextActivated = true; // Set new flag
3670
+ }
3671
+
3672
+ // Check if there was a pending playback request (e.g., from play button)
3673
+ if (audioState.pendingPlaybackRequest) {
3674
+ const { startIndex, isContinuous } = audioState.pendingPlaybackRequest;
3675
+ audioState.pendingPlaybackRequest = null;
3676
+ // We are now enabled, so this will work.
3677
+ playAudioFrom(startIndex, isContinuous);
3678
+ }
3679
+ }
3680
+ });
3681
+ }
3682
+
3683
+ const speedSlider = container.querySelector('#playback-speed');
3684
+ const speedLabel = container.querySelector('#speed-label');
3685
+
3686
+ if (speedSlider) {
3687
+ speedSlider.addEventListener('input', (e) => {
3688
+ const newRate = parseFloat(e.target.value);
3689
+ setPlaybackRate(newRate);
3690
+ if(speedLabel) speedLabel.textContent = newRate.toFixed(1) + 'x';
3691
+ });
3692
+ }
3693
+ }
3694
+
3695
+ function renderPlayerList(container, gameState, actingPlayerName) {
3696
+ container.innerHTML = '<h1>Players</h1>';
3697
+ const listContainer = document.createElement('div');
3698
+ listContainer.id = 'player-list-container';
3699
+ const playerUl = document.createElement('ul');
3700
+ playerUl.id = 'player-list';
3701
+
3702
+ gameState.players.forEach(player => {
3703
+ const li = document.createElement('li');
3704
+ li.className = 'player-card';
3705
+ if (!player.is_alive) li.classList.add('dead');
3706
+ if (player.name === actingPlayerName) li.classList.add('active');
3707
+
3708
+ let roleDisplay = player.role;
3709
+ if (player.role === 'Werewolf') {
3710
+ roleDisplay = `&#x1F43A; ${player.role}`;
3711
+ } else if (player.role === 'Doctor') {
3712
+ roleDisplay = `&#x1FA7A; ${player.role}`;
3713
+ } else if (player.role === 'Seer') {
3714
+ roleDisplay = `&#x1F52E; ${player.role}`;
3715
+ } else if (player.role === 'Villager') {
3716
+ roleDisplay = `&#x1F9D1; ${player.role}`;
3717
+ }
3718
+
3719
+ const roleText = player.role !== 'Unknown' ? `Role: ${roleDisplay}` : 'Role: Unknown';
3720
+
3721
+ li.innerHTML = `
3722
+ <div class="avatar-container">
3723
+ <img src="${player.thumbnail}" alt="${player.name}" class="avatar">
3724
+ </div>
3725
+ <div class="player-info">
3726
+ <div class="player-name" title="${player.name}">${player.name}</div>
3727
+ <div class="player-role">${roleText}</div>
3728
+ </div>
3729
+ <div class="threat-indicator"></div>
3730
+ `;
3731
+ playerUl.appendChild(li);
3732
+ });
3733
+
3734
+ listContainer.appendChild(playerUl);
3735
+ container.appendChild(listContainer);
3736
+
3737
+ gameState.players.forEach((player, index) => {
3738
+ const li = playerUl.children[index];
3739
+ const indicator = li.querySelector('.threat-indicator');
3740
+ if (!indicator) return;
3741
+
3742
+ if (player.is_alive) {
3743
+ const threatLevel = gameState.playerThreatLevels.get(player.name) || 0;
3744
+ indicator.style.backgroundColor = getThreatColor(threatLevel);
3745
+ } else {
3746
+ indicator.style.backgroundColor = 'transparent';
3747
+ }
3748
+ });
3749
+
3750
+ const audioControls = document.createElement('div');
3751
+ audioControls.className = 'audio-controls';
3752
+ audioControls.innerHTML = `
3753
+ <label for="playback-speed">Audio Speed: <span id="speed-label">${audioState.playbackRate.toFixed(1)}</span>x</label>
3754
+ <div style="display: flex; align-items: center; gap: 10px; margin-top: 5px;">
3755
+ <input type="range" id="playback-speed" min="0.5" max="2.5" step="0.1" value="${audioState.playbackRate}" style="flex-grow: 1;">
3756
+ </div>
3757
+ `;
3758
+ container.appendChild(audioControls);
3759
+
3760
+ const speedSlider = audioControls.querySelector('#playback-speed');
3761
+ const speedLabel = audioControls.querySelector('#speed-label');
3762
+
3763
+ speedSlider.addEventListener('input', (e) => {
3764
+ const newRate = parseFloat(e.target.value);
3765
+ setPlaybackRate(newRate);
3766
+ speedLabel.textContent = newRate.toFixed(1);
3767
+ });
3768
+ }
3769
+
3770
+ // --- Main Rendering Logic (Incremental) ---
3771
+ // Only create UI elements if they don't exist
3772
+ let mainContainer = parent.querySelector('.main-container');
3773
+ let style = parent.querySelector('style');
3774
+
3775
+ if (!style) {
3776
+ style = document.createElement('style');
3777
+ style.textContent = css;
3778
+ parent.appendChild(style);
3779
+ }
3780
+
3781
+ initThreeJs();
3782
+
3783
+ if (!environment || !environment.steps || environment.steps.length === 0 || step >= environment.steps.length) {
3784
+ if (!mainContainer) {
3785
+ const tempContainer = document.createElement("div");
3786
+ tempContainer.textContent = "Waiting for game data or invalid step...";
3787
+ parent.appendChild(tempContainer);
3788
+ }
3789
+ return;
3790
+ }
3791
+
3792
+ // Initialize player mapping for 3D scene
3793
+ let playerNamesFor3D = [];
3794
+ let playerThumbnailsFor3D = {};
3795
+
3796
+ // --- State Reconstruction ---
3797
+ const player = window.werewolfGamePlayer;
3798
+ const { allEvents, displayStepToAllEventsIndex, originalSteps, eventToKaggleStep } = player;
3799
+
3800
+ if (step >= displayStepToAllEventsIndex.length) {
3801
+ console.error("Step is out of bounds for displayStepToAllEventsIndex", step, displayStepToAllEventsIndex.length);
3802
+ return;
3803
+ }
3804
+ const allEventsIndex = displayStepToAllEventsIndex[step];
3805
+ const eventStep = allEventsIndex; // for clarity
3806
+ const kaggleStep = eventToKaggleStep[eventStep] || 0;
3807
+
3808
+ let gameState = {
3809
+ players: [],
3810
+ day: 0,
3811
+ phase: 'GAME_SETUP',
3812
+ game_state_phase: 'DAY',
3813
+ gameWinner: null,
3814
+ eventLog: [],
3815
+ playerThreatLevels: new Map()
3816
+ };
3817
+
3818
+ const firstObs = originalSteps[0]?.[0]?.observation?.raw_observation;
3819
+ let allPlayerNamesList;
3820
+ let playerThumbnails = {};
3821
+
3822
+ if (firstObs && firstObs.all_player_ids) {
3823
+ allPlayerNamesList = firstObs.all_player_ids;
3824
+ playerThumbnails = firstObs.player_thumbnails || {};
3825
+ playerNamesFor3D = [...allPlayerNamesList];
3826
+ playerThumbnailsFor3D = {...playerThumbnails};
3827
+ } else if (environment.configuration && environment.configuration.agents) {
3828
+ // console.warn("Renderer: Initial observation missing or incomplete. Reconstructing players from configuration.");
3829
+ allPlayerNamesList = environment.configuration.agents.map(agent => agent.id);
3830
+ environment.configuration.agents.forEach(agent => {
3831
+ if (agent.id && agent.thumbnail) {
3832
+ playerThumbnails[agent.id] = agent.thumbnail;
3833
+ }
3834
+ });
3835
+ playerNamesFor3D = [...allPlayerNamesList];
3836
+ playerThumbnailsFor3D = {...playerThumbnails};
3837
+ }
3838
+
3839
+ if (!allPlayerNamesList || allPlayerNamesList.length === 0) {
3840
+ const tempContainer = document.createElement("div");
3841
+ tempContainer.textContent = "Waiting for game data: No players found in observation or configuration.";
3842
+ parent.appendChild(tempContainer);
3843
+ return;
3844
+ }
3845
+
3846
+ gameState.players = environment.configuration.agents.map( agent => ({
3847
+ name: agent.id, is_alive: true, role: agent.role, team: 'Unknown',
3848
+ status: 'Alive', thumbnail: agent.thumbnail || `https://via.placeholder.com/40/2c3e50/ecf0f1?text=${agent.id.charAt(0)}`,
3849
+ display_name: agent.display_name
3850
+ }));
3851
+ const playerMap = new Map(gameState.players.map(p => [p.name, p]));
3852
+
3853
+ // Initialize and cache the replacer function if it doesn't exist
3854
+ if (!player.playerIdReplacer) {
3855
+ player.playerIdReplacer = createPlayerIdReplacer(playerMap);
3856
+ }
3857
+
3858
+ gameState.players.forEach(p => gameState.playerThreatLevels.set(p.name, 0));
3859
+
3860
+ const roleAndTeamMap = new Map();
3861
+ const moderatorInitialLog = environment.info?.MODERATOR_OBSERVATION?.[0] || [];
3862
+ moderatorInitialLog.flat().forEach(dataEntry => {
3863
+ if (dataEntry.data_type === 'GameStartRoleDataEntry') {
3864
+ const historyEvent = JSON.parse(dataEntry.json_str);
3865
+ const data = historyEvent.data;
3866
+ if (data) roleAndTeamMap.set(data.player_id, { role: data.role, team: data.team });
3867
+ }
3868
+ });
3869
+ roleAndTeamMap.forEach((info, playerId) => {
3870
+ const player = playerMap.get(playerId);
3871
+ if (player) { player.role = info.role; player.team = info.team; }
3872
+ });
3873
+
3874
+ function threatStringToLevel(threatString) {
3875
+ switch(threatString) {
3876
+ case 'SAFE': return 0;
3877
+ case 'UNEASY': return 0.5;
3878
+ case 'DANGER': return 1.0;
3879
+ default: return 0;
3880
+ }
3881
+ }
3882
+
3883
+ // Reconstruct state up to current kaggleStep
3884
+ for (let s = 0; s <= kaggleStep; s++) {
3885
+ const stepStateList = originalSteps[s];
3886
+ if (!stepStateList) continue;
3887
+
3888
+ const currentObsForStep = stepStateList[0]?.observation?.raw_observation;
3889
+ if (currentObsForStep) {
3890
+ gameState.day = currentObsForStep.day;
3891
+ gameState.phase = currentObsForStep.phase;
3892
+ gameState.game_state_phase = currentObsForStep.game_state_phase;
3893
+ }
3894
+ }
3895
+
3896
+ // Populate event log up to current eventStep
3897
+ for (let i = 0; i <= eventStep; i++) {
3898
+ const historyEvent = allEvents[i];
3899
+ const data = historyEvent.data;
3900
+ const timestamp = historyEvent.created_at;
3901
+
3902
+ if (data && data.actor_id && data.perceived_threat_level) {
3903
+ const threatScore = threatStringToLevel(data.perceived_threat_level);
3904
+ gameState.playerThreatLevels.set(data.actor_id, threatScore);
3905
+ }
3906
+
3907
+ if (!data) {
3908
+ if (historyEvent.event_name === 'vote_action') {
3909
+ const match = historyEvent.description.match(/P(player_\d+)/);
3910
+ if (match) {
3911
+ const actor_id = match[1];
3912
+ gameState.eventLog.push({ type: 'timeout', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: actor_id, reasoning: "Timed out", timestamp: historyEvent.created_at });
3913
+ }
3914
+ } else if (historyEvent.event_name === 'day_start' || historyEvent.event_name === 'night_start') {
3915
+ gameState.eventLog.push({ type: 'system', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, text: historyEvent.description, allEventsIndex: i, timestamp});
3916
+ }
3917
+ continue;
3918
+ }
3919
+
3920
+ switch (historyEvent.dataType) {
3921
+ case 'ChatDataEntry':
3922
+ gameState.eventLog.push({ type: 'chat', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, speaker: data.actor_id, message: data.message, reasoning: data.reasoning, timestamp, allEventsIndex: i, mentioned_player_ids: data.mentioned_player_ids || [] });
3923
+ break;
3924
+ case 'DayExileVoteDataEntry':
3925
+ gameState.eventLog.push({ type: 'vote', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, allEventsIndex: i, timestamp });
3926
+ break;
3927
+ case 'WerewolfNightVoteDataEntry':
3928
+ gameState.eventLog.push({ type: 'night_vote', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, allEventsIndex: i, timestamp });
3929
+ break;
3930
+ case 'DoctorHealActionDataEntry':
3931
+ gameState.eventLog.push({ type: 'doctor_heal_action', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, allEventsIndex: i, timestamp });
3932
+ break;
3933
+ case 'SeerInspectActionDataEntry':
3934
+ gameState.eventLog.push({ type: 'seer_inspection', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, target: data.target_id, reasoning: data.reasoning, allEventsIndex: i, timestamp });
3935
+ break;
3936
+ case 'DayExileElectedDataEntry':
3937
+ gameState.eventLog.push({ type: 'exile', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, name: data.elected_player_id, role: data.elected_player_role_name, allEventsIndex: i, timestamp });
3938
+ break;
3939
+ case 'WerewolfNightEliminationDataEntry':
3940
+ gameState.eventLog.push({ type: 'elimination', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, name: data.eliminated_player_id, role: data.eliminated_player_role_name, allEventsIndex: i, timestamp });
3941
+ break;
3942
+ case 'SeerInspectResultDataEntry':
3943
+ gameState.eventLog.push({ type: 'seer_inspection_result', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, actor_id: data.actor_id, seer: data.actor_id, target: data.target_id, role: data.role, team: data.team, allEventsIndex: i, timestamp });
3944
+ break;
3945
+ case 'DoctorSaveDataEntry':
3946
+ gameState.eventLog.push({ type: 'save', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, saved_player: data.saved_player_id, allEventsIndex: i, timestamp });
3947
+ break;
3948
+ case 'PhaseDividerDataEntry':
3949
+ gameState.eventLog.push({ type: 'phase_divider', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, divider: data.divider_type, allEventsIndex: i, timestamp });
3950
+ break;
3951
+ case 'GameEndResultsDataEntry':
3952
+ gameState.gameWinner = data.winner_team;
3953
+ const winners = gameState.players.filter(p => p.team === data.winner_team).map(p => p.name);
3954
+ const losers = gameState.players.filter(p => p.team !== data.winner_team).map(p => p.name);
3955
+ gameState.eventLog.push({ type: 'game_over', step: historyEvent.kaggleStep, day: Infinity, phase: 'GAME_OVER', winner: data.winner_team, winners, losers, allEventsIndex: i, timestamp });
3956
+ break;
3957
+ case 'DiscussionOrderDataEntry':
3958
+ gameState.eventLog.push({ type: 'system', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, text: historyEvent.description, allEventsIndex: i, timestamp });
3959
+ break;
3960
+ default:
3961
+ if (systemEntryTypeSet.has(historyEvent.event_name)) {
3962
+ gameState.eventLog.push({ type: 'system', step: historyEvent.kaggleStep, day: historyEvent.day, phase: historyEvent.phase, text: historyEvent.description, allEventsIndex: i, timestamp, data: data});
3963
+ }
3964
+ break;
3965
+ }
3966
+ }
3967
+
3968
+ if (eventStep < audioState.lastPlayedStep) {
3969
+ audioState.audioQueue = [];
3970
+ audioState.isAudioPlaying = false;
3971
+ if (audioState.audioPlayer) {
3972
+ audioState.audioPlayer.pause();
3973
+ }
3974
+ const chatLog = parent.querySelector('#chat-log');
3975
+ if (chatLog) {
3976
+ chatLog.innerHTML = '';
3977
+ }
3978
+ }
3979
+
3980
+ audioState.lastPlayedStep = eventStep;
3981
+ sessionStorage.setItem('ww_lastPlayedStep', eventStep);
3982
+
3983
+ gameState.players.forEach(p => { p.is_alive = true; p.status = 'Alive'; });
3984
+ gameState.eventLog.forEach(entry => {
3985
+ if (entry.type === 'exile' || entry.type === 'elimination') {
3986
+ const player = playerMap.get(entry.name);
3987
+ if (player) {
3988
+ player.is_alive = false;
3989
+ player.status = entry.type === 'exile' ? 'Exiled' : 'Eliminated';
3990
+ }
3991
+ }
3992
+ });
3993
+
3994
+ // 1. Get the actual event being displayed at this step.
3995
+ const currentEvent = allEvents[eventStep];
3996
+ let nameToHighlight = null; // Initialize to null as requested.
3997
+
3998
+ if (currentEvent) {
3999
+ // 2. Check for a player actor in the event's data (covers chat, votes, night actions).
4000
+ if (currentEvent.data && currentEvent.data.actor_id) {
4001
+ nameToHighlight = currentEvent.data.actor_id;
4002
+ }
4003
+ // 3. Handle the special case for a timeout.
4004
+ else if (currentEvent.event_name === 'vote_action' && !currentEvent.data) {
4005
+ const match = currentEvent.description.match(/P(player_\d+)/);
4006
+ if (match && playerMap.has(match[1])) {
4007
+ nameToHighlight = match[1];
4008
+ }
4009
+ }
4010
+ }
4011
+
4012
+ Object.assign(parent.style, { width: `${width}px`, height: `${height}px` });
4013
+ parent.className = 'werewolf-parent';
4014
+
4015
+ // Create or get existing main container
4016
+ if (!mainContainer) {
4017
+ mainContainer = document.createElement('div');
4018
+ mainContainer.className = 'main-container';
4019
+ parent.appendChild(mainContainer);
4020
+ }
4021
+
4022
+ // Create or update phase indicator
4023
+ let phaseIndicator = parent.querySelector('.phase-indicator');
4024
+ if (!phaseIndicator) {
4025
+ phaseIndicator = document.createElement('div');
4026
+ phaseIndicator.className = 'phase-indicator';
4027
+ parent.appendChild(phaseIndicator);
4028
+ }
4029
+
4030
+ // Update phase indicator based on current game state
4031
+ const currentPhase = allEvents[eventStep].phase.toUpperCase() || 'DAY';
4032
+ const isNight = currentPhase === 'NIGHT';
4033
+ phaseIndicator.className = `phase-indicator ${isNight ? 'night' : 'day'}`;
4034
+ if (allEvents[eventStep]?.event_name == 'game_end') {
4035
+ phaseIndicator.innerHTML = `
4036
+ <span class="phase-icon">${isNight ? '&#x1F319;' : '&#x2600;'}</span>
4037
+ `;
4038
+ } else {
4039
+ phaseIndicator.innerHTML = `
4040
+ <span class="phase-icon">${isNight ? '&#x1F319;' : '&#x2600;'}</span>
4041
+ <span>${allEvents[eventStep].day}</span>
4042
+ `;
4043
+ }
4044
+
4045
+ // Create or update game scoreboard
4046
+ let scoreboard = parent.querySelector('.game-scoreboard');
4047
+ if (!scoreboard) {
4048
+ scoreboard = document.createElement('div');
4049
+ scoreboard.className = 'game-scoreboard';
4050
+ parent.appendChild(scoreboard);
4051
+ }
4052
+
4053
+ // Calculate game statistics
4054
+ const alivePlayers = gameState.players.filter(p => p.is_alive).length;
4055
+ const deadPlayers = gameState.players.filter(p => !p.is_alive).length;
4056
+ const werewolves = gameState.players.filter(p => p.is_alive && p.role === 'Werewolf').length;
4057
+ const villagers = gameState.players.filter(p => p.is_alive && p.role !== 'Werewolf' && p.role !== 'Unknown').length;
4058
+
4059
+ // Determine current action based on phase and recent events
4060
+ let currentAction = 'Waiting...';
4061
+ const lastEvent = gameState.eventLog[gameState.eventLog.length - 1];
4062
+
4063
+ if (gameState.gameWinner) {
4064
+ currentAction = `${gameState.gameWinner} Win!`;
4065
+ } else if (gameState.phase === 'VOTING') {
4066
+ currentAction = 'Voting Phase';
4067
+ } else if (gameState.phase === 'DISCUSSION') {
4068
+ currentAction = 'Discussion';
4069
+ } else if (isNight) {
4070
+ // Check for recent night actions
4071
+ if (lastEvent) {
4072
+ if (lastEvent.type === 'night_vote') {
4073
+ currentAction = 'Werewolves Voting';
4074
+ } else if (lastEvent.type === 'doctor_heal_action') {
4075
+ currentAction = 'Doctor Saving';
4076
+ } else if (lastEvent.type === 'seer_inspection') {
4077
+ currentAction = 'Seer Inspecting';
4078
+ } else {
4079
+ currentAction = 'Night Actions';
4080
+ }
4081
+ } else {
4082
+ currentAction = 'Night Phase';
4083
+ }
4084
+ } else {
4085
+ // Day phase
4086
+ if (lastEvent && lastEvent.type === 'chat') {
4087
+ currentAction = 'Discussion';
4088
+ } else if (lastEvent && lastEvent.type === 'vote') {
4089
+ currentAction = 'Exile Voting';
4090
+ } else {
4091
+ currentAction = 'Day Phase';
4092
+ }
4093
+ }
4094
+
4095
+ // Update scoreboard content
4096
+ scoreboard.innerHTML = `
4097
+ <div class="scoreboard-item">
4098
+ <div class="scoreboard-label">Day</div>
4099
+ <div class="scoreboard-value">${gameState.day || 0}</div>
4100
+ </div>
4101
+ <div class="scoreboard-item">
4102
+ <div class="scoreboard-label">Alive</div>
4103
+ <div class="scoreboard-value alive">${alivePlayers}</div>
4104
+ </div>
4105
+ <div class="scoreboard-item">
4106
+ <div class="scoreboard-label">Out</div>
4107
+ <div class="scoreboard-value dead">${deadPlayers}</div>
4108
+ </div>
4109
+ ${werewolves > 0 || villagers > 0 ? `
4110
+ <div class="scoreboard-item">
4111
+ <div class="scoreboard-label">Werewolves</div>
4112
+ <div class="scoreboard-value werewolf">${werewolves}</div>
4113
+ </div>
4114
+ <div class="scoreboard-item">
4115
+ <div class="scoreboard-label">Villagers</div>
4116
+ <div class="scoreboard-value villager">${villagers}</div>
4117
+ </div>
4118
+ ` : ''}
4119
+ <div class="scoreboard-item">
4120
+ <div class="scoreboard-action">${currentAction}</div>
4121
+ </div>
4122
+ `;
4123
+
4124
+ // Create or get existing panels
4125
+ let leftPanel = mainContainer.querySelector('.left-panel');
4126
+ if (!leftPanel) {
4127
+ leftPanel = document.createElement('div');
4128
+ leftPanel.className = 'left-panel';
4129
+ mainContainer.appendChild(leftPanel);
4130
+ }
4131
+
4132
+ let playerListArea = leftPanel.querySelector('#player-list-area');
4133
+ if (!playerListArea) {
4134
+ playerListArea = document.createElement('div');
4135
+ playerListArea.id = 'player-list-area';
4136
+ leftPanel.appendChild(playerListArea);
4137
+ }
4138
+
4139
+ let rightPanel = mainContainer.querySelector('.right-panel');
4140
+ if (!rightPanel) {
4141
+ rightPanel = document.createElement('div');
4142
+ rightPanel.className = 'right-panel';
4143
+ mainContainer.appendChild(rightPanel);
4144
+ }
4145
+
4146
+ // Update existing content instead of clearing and rebuilding
4147
+ updatePlayerList(playerListArea, gameState, nameToHighlight);
4148
+ updateEventLog(rightPanel, gameState, playerMap);
4149
+
4150
+ // Update 3D scene based on game state
4151
+ updateSceneFromGameState(gameState, playerMap, nameToHighlight);
4152
+
4153
+ // Initialize 3D players if needed
4154
+ if (threeState.demo && threeState.demo._playerObjects && threeState.demo._playerObjects.size === 0 && playerNamesFor3D.length > 0) {
4155
+ initializePlayers3D(gameState, playerNamesFor3D, playerThumbnailsFor3D, threeState);
4156
+ }
4157
+ }
4158
+
4159
+ function initializePlayers3D(gameState, playerNames, playerThumbnails, threeState) {
4160
+ if (!threeState || !threeState.demo || !threeState.demo._playerObjects) return;
4161
+
4162
+ // Clear existing player objects
4163
+ if (threeState.demo._playerGroup) {
4164
+ // Remove all children from the group
4165
+ while(threeState.demo._playerGroup.children.length > 0) {
4166
+ threeState.demo._playerGroup.remove(threeState.demo._playerGroup.children[0]);
4167
+ }
4168
+ }
4169
+ threeState.demo._playerObjects.clear();
4170
+
4171
+ const numPlayers = playerNames.length;
4172
+ const radius = 18; // Increased radius to use more space
4173
+ const playerHeight = 4;
4174
+
4175
+ const THREE = threeState.demo._THREE;
4176
+ const CSS2DObject = threeState.demo._CSS2DObject;
4177
+
4178
+ // Create a circular platform
4179
+ const platformGeometry = new THREE.RingGeometry(radius - 2, radius + 3, 64);
4180
+ const platformMaterial = new THREE.MeshStandardMaterial({
4181
+ color: 0x2a2a3a,
4182
+ roughness: 0.9,
4183
+ metalness: 0.1,
4184
+ transparent: true,
4185
+ opacity: 0.5,
4186
+ side: THREE.DoubleSide
4187
+ });
4188
+ const platform = new THREE.Mesh(platformGeometry, platformMaterial);
4189
+ platform.rotation.x = -Math.PI / 2;
4190
+ platform.position.y = -0.05;
4191
+ platform.receiveShadow = true;
4192
+ threeState.demo._playerGroup.add(platform);
4193
+
4194
+ // Create center decoration
4195
+ const centerGeometry = new THREE.CylinderGeometry(3, 3, 0.2, 32);
4196
+ const centerMaterial = new THREE.MeshStandardMaterial({
4197
+ color: 0x444466,
4198
+ roughness: 0.7,
4199
+ metalness: 0.3,
4200
+ emissive: 0x222244,
4201
+ emissiveIntensity: 0.2
4202
+ });
4203
+ const centerPlatform = new THREE.Mesh(centerGeometry, centerMaterial);
4204
+ centerPlatform.position.y = 0.1;
4205
+ centerPlatform.receiveShadow = true;
4206
+ threeState.demo._playerGroup.add(centerPlatform);
4207
+
4208
+ // Add decorative lines from center to each player position
4209
+ const linesMaterial = new THREE.LineBasicMaterial({
4210
+ color: 0x334455,
4211
+ transparent: true,
4212
+ opacity: 0.3
4213
+ });
4214
+
4215
+ playerNames.forEach((name, i) => {
4216
+ const displayName = gameState.players[i].display_name || '';
4217
+ const playerContainer = new THREE.Group();
4218
+ // Use full circle (360 degrees)
4219
+ const angle = (i / numPlayers) * Math.PI * 2;
4220
+
4221
+ const x = radius * Math.sin(angle);
4222
+ const z = radius * Math.cos(angle);
4223
+ playerContainer.position.set(x, 0, z);
4224
+
4225
+ // Create line from center to player
4226
+ const lineGeometry = new THREE.BufferGeometry().setFromPoints([
4227
+ new THREE.Vector3(0, 0.05, 0),
4228
+ new THREE.Vector3(x, 0.05, z)
4229
+ ]);
4230
+ const line = new THREE.Line(lineGeometry, linesMaterial);
4231
+ threeState.demo._playerGroup.add(line);
4232
+
4233
+ // Create pedestal for each player
4234
+ const pedestalGeometry = new THREE.CylinderGeometry(1.5, 1.8, 0.4, 16);
4235
+ const pedestalMaterial = new THREE.MeshStandardMaterial({
4236
+ color: 0x333344,
4237
+ roughness: 0.8,
4238
+ metalness: 0.2,
4239
+ emissive: 0x111122,
4240
+ emissiveIntensity: 0.1
4241
+ });
4242
+ const pedestal = new THREE.Mesh(pedestalGeometry, pedestalMaterial);
4243
+ pedestal.position.y = 0.2;
4244
+ pedestal.castShadow = true;
4245
+ pedestal.receiveShadow = true;
4246
+ playerContainer.add(pedestal);
4247
+
4248
+ // Create player body (more detailed)
4249
+ const bodyGeometry = new THREE.CylinderGeometry(0.8, 1, playerHeight * 0.6, 16);
4250
+ const bodyMaterial = new THREE.MeshStandardMaterial({
4251
+ color: 0x4466ff,
4252
+ roughness: 0.5,
4253
+ metalness: 0.3,
4254
+ emissive: 0x111166,
4255
+ emissiveIntensity: 0.2
4256
+ });
4257
+ const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
4258
+ body.position.y = playerHeight * 0.4;
4259
+ body.castShadow = true;
4260
+ body.receiveShadow = true;
4261
+ playerContainer.add(body);
4262
+
4263
+ // Create shoulders
4264
+ const shoulderGeometry = new THREE.SphereGeometry(1, 16, 8);
4265
+ const shoulderMaterial = new THREE.MeshStandardMaterial({
4266
+ color: 0x4466ff,
4267
+ roughness: 0.5,
4268
+ metalness: 0.3,
4269
+ emissive: 0x111166,
4270
+ emissiveIntensity: 0.2
4271
+ });
4272
+ const shoulders = new THREE.Mesh(shoulderGeometry, shoulderMaterial);
4273
+ shoulders.position.y = playerHeight * 0.65;
4274
+ shoulders.scale.set(1.2, 0.6, 0.8);
4275
+ shoulders.castShadow = true;
4276
+ playerContainer.add(shoulders);
4277
+
4278
+ // Create player head (sphere)
4279
+ const headGeometry = new THREE.SphereGeometry(0.7, 16, 16);
4280
+ const headMaterial = new THREE.MeshStandardMaterial({
4281
+ color: 0xfdbcb4,
4282
+ roughness: 0.7,
4283
+ metalness: 0.1,
4284
+ emissive: 0x442211,
4285
+ emissiveIntensity: 0.1
4286
+ });
4287
+ const head = new THREE.Mesh(headGeometry, headMaterial);
4288
+ head.position.y = playerHeight * 0.85;
4289
+ head.castShadow = true;
4290
+ head.receiveShadow = true;
4291
+ playerContainer.add(head);
4292
+
4293
+ // Create eyes
4294
+ const eyeGeometry = new THREE.SphereGeometry(0.08, 8, 6);
4295
+ const eyeMaterial = new THREE.MeshStandardMaterial({
4296
+ color: 0x000000,
4297
+ roughness: 0.3,
4298
+ metalness: 0.8
4299
+ });
4300
+ const leftEye = new THREE.Mesh(eyeGeometry, eyeMaterial);
4301
+ leftEye.position.set(-0.2, playerHeight * 0.87, 0.6);
4302
+ playerContainer.add(leftEye);
4303
+
4304
+ const rightEye = new THREE.Mesh(eyeGeometry, eyeMaterial);
4305
+ rightEye.position.set(0.2, playerHeight * 0.87, 0.6);
4306
+ playerContainer.add(rightEye);
4307
+
4308
+ // Create glowing orb for status (more dramatic)
4309
+ const orbGeometry = new THREE.IcosahedronGeometry(0.3, 2);
4310
+ const orbMaterial = new THREE.MeshStandardMaterial({
4311
+ color: 0x00ff00,
4312
+ emissive: 0x00ff00,
4313
+ emissiveIntensity: 0.8,
4314
+ transparent: true,
4315
+ opacity: 0.9
4316
+ });
4317
+ const orb = new THREE.Mesh(orbGeometry, orbMaterial);
4318
+ orb.position.y = playerHeight * 1.2;
4319
+ orb.name = 'statusOrb';
4320
+ playerContainer.add(orb);
4321
+
4322
+ // Add outer glow sphere
4323
+ const glowGeometry = new THREE.SphereGeometry(0.5, 12, 8);
4324
+ const glowMaterial = new THREE.MeshStandardMaterial({
4325
+ color: 0x00ff00,
4326
+ emissive: 0x00ff00,
4327
+ emissiveIntensity: 0.3,
4328
+ transparent: true,
4329
+ opacity: 0.3
4330
+ });
4331
+ const glow = new THREE.Mesh(glowGeometry, glowMaterial);
4332
+ glow.position.y = playerHeight * 1.2;
4333
+ playerContainer.add(glow);
4334
+
4335
+ // Add point light for glow effect
4336
+ const orbLight = new THREE.PointLight(0x00ff00, 0.8, 8);
4337
+ orbLight.position.y = playerHeight * 1.2;
4338
+ orbLight.name = 'orbLight';
4339
+ orbLight.castShadow = true;
4340
+ playerContainer.add(orbLight);
4341
+
4342
+ // Make player face center without flipping
4343
+ // Calculate the angle to face the center
4344
+ playerContainer.rotation.y = -angle + Math.PI / 2;
4345
+
4346
+ // Create nameplate with actual player thumbnail
4347
+ const thumbnailUrl = playerThumbnails[name] || `https://via.placeholder.com/60/2c3e50/ecf0f1?text=${name.charAt(0)}`;
4348
+ const nameplate = threeState.demo._createNameplate(name, displayName, thumbnailUrl, CSS2DObject);
4349
+ nameplate.position.set(0, playerHeight * 2.0, 0);
4350
+ playerContainer.add(nameplate);
4351
+
4352
+ // Store references
4353
+ threeState.demo._playerObjects.set(name, {
4354
+ container: playerContainer,
4355
+ body: body,
4356
+ head: head,
4357
+ shoulders: shoulders,
4358
+ orb: orb,
4359
+ glow: glow,
4360
+ orbLight: orbLight,
4361
+ nameplate: nameplate,
4362
+ pedestal: pedestal,
4363
+ originalPosition: playerContainer.position.clone(),
4364
+ baseAngle: angle,
4365
+ isAlive: true
4366
+ });
4367
+
4368
+ threeState.demo._playerGroup.add(playerContainer);
4369
+ });
4370
+
4371
+ // Adjust camera to see the full circle
4372
+ if (threeState.demo._camera) {
4373
+ threeState.demo._camera.position.set(25, 30, 25);
4374
+ threeState.demo._controls.target.set(0, 5, 0);
4375
+ threeState.demo._controls.update();
4376
+ }
4377
+ }