918 lines
41 KiB
JavaScript
918 lines
41 KiB
JavaScript
// ══════════════════════════════════════════════════════
|
|
// GREEK ALPHABET (mirrors server for display)
|
|
// ══════════════════════════════════════════════════════
|
|
const GREEK = ["Alpha","Beta","Gamma","Delta","Epsilon","Zeta","Eta","Theta","Iota","Kappa","Lambda","Mu","Nu","Xi","Omicron","Pi","Rho","Sigma","Tau","Upsilon","Phi","Chi","Psi","Omega"];
|
|
function toRoman(n){const vals=[1000,900,500,400,100,90,50,40,10,9,5,4,1];const syms=["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"];let out="";for(let i=0;i<vals.length;i++)while(n>=vals[i]){out+=syms[i];n-=vals[i];}return out;}
|
|
function greekName(i){const cycle=Math.floor(i/GREEK.length);return cycle===0?GREEK[i%GREEK.length]:`${GREEK[i%GREEK.length]} ${toRoman(cycle+1)}`;}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// COLORS — HSL wheel, infinite unique colors
|
|
// ══════════════════════════════════════════════════════
|
|
function teamColor(i){const hue=(i*137.508)%360;return `hsl(${hue},90%,58%)`;}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// STATE
|
|
// ══════════════════════════════════════════════════════
|
|
let ws=null,role=null,room=null,myId=null;
|
|
let reconnTimer=null,reconnAttempts=0;
|
|
let modTimerInterval=null,modTimerRemaining=0,modTimerRunning=false;
|
|
let playerTimerRemaining=0,playerTimerInterval=null;
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// STORAGE
|
|
// ══════════════════════════════════════════════════════
|
|
const saveMod=(id,s)=>localStorage.setItem('mod',JSON.stringify({id,s}));
|
|
const loadMod=()=>{try{return JSON.parse(localStorage.getItem('mod')||'null');}catch{return null;}};
|
|
const clearMod=()=>{localStorage.removeItem('mod');document.getElementById('rejoin-bar').style.display='none';};
|
|
const savePlay=(rid,pid)=>localStorage.setItem('play',JSON.stringify({rid,pid}));
|
|
const loadPlay=()=>{try{return JSON.parse(localStorage.getItem('play')||'null');}catch{return null;}};
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// WEBSOCKET
|
|
// ══════════════════════════════════════════════════════
|
|
function connect(onOpen){
|
|
if(ws&&ws.readyState===WebSocket.OPEN){onOpen?.();return;}
|
|
const proto=location.protocol==='https:'?'wss':'ws';
|
|
ws=new WebSocket(`${proto}://${location.host}/ws`);
|
|
ws.onopen=()=>{setConn(true);reconnAttempts=0;onOpen?.();};
|
|
ws.onmessage=e=>{try{handle(JSON.parse(e.data));}catch{}};
|
|
ws.onclose=()=>{setConn(false);schedReconn();};
|
|
ws.onerror=()=>ws.close();
|
|
}
|
|
function ws_send(msg){if(ws?.readyState===WebSocket.OPEN)ws.send(JSON.stringify(msg));}
|
|
function schedReconn(){
|
|
clearTimeout(reconnTimer);
|
|
const d=Math.min(8000,500*Math.pow(1.5,reconnAttempts++));
|
|
reconnTimer=setTimeout(()=>{
|
|
if(role==='mod'){const m=loadMod();if(m)connect(()=>ws_send({type:'mod_rejoin',roomId:m.id,modSecret:m.s}));}
|
|
else if(role==='player'&&myId){const p=loadPlay();if(p)connect(()=>ws_send({type:'join_room',roomId:p.rid,playerName:p.name,playerId:p.pid}));}
|
|
else connect(null);
|
|
},d);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// MESSAGES
|
|
// ══════════════════════════════════════════════════════
|
|
function handle(msg){
|
|
switch(msg.type){
|
|
case 'room_created':
|
|
saveMod(msg.roomId,msg.modSecret);
|
|
room=msg.room;role='mod';
|
|
showScr('s-mod');renderMod();renderModSettings();
|
|
toast('Room '+msg.roomId+' created','ok');
|
|
break;
|
|
case 'mod_joined':
|
|
room=msg.room;role='mod';
|
|
showScr('s-mod');renderMod();renderModSettings();
|
|
toast('Rejoined as moderator','ok');
|
|
break;
|
|
case 'joined':
|
|
myId=msg.playerId;room=msg.room;role='player';
|
|
savePlay(room.id,myId);
|
|
showScr('s-player');renderPlayer();
|
|
break;
|
|
case 'room_update':
|
|
room=msg.room;
|
|
if(role==='mod'){renderMod();renderRoundButtons();}else renderPlayer();
|
|
break;
|
|
case 'settings_updated':
|
|
room=msg.room;
|
|
if(role==='mod'){renderMod();renderRoundButtons();renderModSettings();}else renderPlayer();
|
|
break;
|
|
case 'new_round':
|
|
toast('NEW ROUND STARTED','ok');
|
|
break;
|
|
case 'round_open':
|
|
room=msg.room;
|
|
if(role==='mod'){renderMod();renderRoundButtons();}
|
|
else{renderPlayerBuzzer();startPlayerTimer();}
|
|
// If timer is enabled and this is a fresh open, load the timer
|
|
if(room.settings.timerSeconds>0 && modTimerRemaining===0 && !modTimerRunning){
|
|
modTimerRemaining=room.settings.timerSeconds;
|
|
renderModTimerDisplay();
|
|
}
|
|
break;
|
|
case 'round_closed':
|
|
room=msg.room;
|
|
if(role==='mod'){renderMod();renderRoundButtons();toast('ROUND CLOSED','warn');}
|
|
else{renderPlayerBuzzer();stopPlayerTimer();}
|
|
break;
|
|
case 'buzzer_reset':
|
|
room=msg.room;
|
|
if(role==='mod'){renderMod();renderRoundButtons();}else renderPlayerBuzzer();
|
|
break;
|
|
case 'buzz_event':
|
|
room=msg.room;
|
|
if(role==='mod'){renderMod();renderRoundButtons();}else{renderPlayerBuzzer();addFeed(msg);}
|
|
break;
|
|
case 'buzz_rejected':
|
|
toast(msg.reason,'warn');break;
|
|
case 'kicked':
|
|
toast('Removed from room','err');
|
|
role=null;room=null;myId=null;localStorage.removeItem('play');
|
|
showScr('s-land');break;
|
|
case 'room_ended':
|
|
toast('Room ended','warn');
|
|
role=null;room=null;myId=null;
|
|
clearMod();localStorage.removeItem('play');
|
|
showScr('s-land');break;
|
|
case 'mod_disconnected':
|
|
toast('Moderator disconnected','warn');break;
|
|
case 'error':
|
|
toast(msg.message,'err');break;
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// SCREENS — with GSAP transitions
|
|
// ══════════════════════════════════════════════════════
|
|
let _currentScr = null;
|
|
function showScr(id){
|
|
const prev = _currentScr;
|
|
const next = document.getElementById(id);
|
|
|
|
// Hide all screens
|
|
document.querySelectorAll('.scr').forEach(s=>{
|
|
if(s.id !== id){
|
|
s.style.display='none';
|
|
s.classList.remove('on');
|
|
}
|
|
});
|
|
|
|
next.style.display='flex';
|
|
next.classList.add('on');
|
|
_currentScr = id;
|
|
|
|
// GSAP entrance
|
|
if(typeof gsap !== 'undefined'){
|
|
gsap.fromTo(next,
|
|
{opacity:0, y:prev?14:22},
|
|
{opacity:1, y:0, duration:0.45, ease:'power3.out'}
|
|
);
|
|
// stagger children
|
|
const children = next.querySelectorAll(':scope > *');
|
|
gsap.fromTo(children,
|
|
{opacity:0, y:16},
|
|
{opacity:1, y:0, duration:0.5, stagger:0.06, ease:'power3.out', delay:0.05}
|
|
);
|
|
} else {
|
|
next.style.opacity='1';
|
|
}
|
|
|
|
// Update room chip
|
|
const chip=document.getElementById('hdr-room');
|
|
if(room?.id){chip.textContent='['+room.id+']';chip.style.display='block';}
|
|
else chip.style.display='none';
|
|
}
|
|
|
|
function setConn(on){
|
|
document.getElementById('cdot').className='conn-dot'+(on?' on':'');
|
|
document.getElementById('clbl').textContent=on?'ONLINE':'OFFLINE';
|
|
if(on && typeof gsap!=='undefined'){
|
|
gsap.fromTo('#clbl',{opacity:0.3},{opacity:1,duration:0.4});
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// LANDING
|
|
// ══════════════════════════════════════════════════════
|
|
function goSetup(){renderSetupTeamNames();showScr('s-setup');}
|
|
function joinRoom(){
|
|
const code=document.getElementById('ji-code').value.trim().toUpperCase();
|
|
if(!code){toast('Enter room code','err');return;}
|
|
connect(()=>ws_send({type:'join_room',roomId:code}));
|
|
}
|
|
function openRejoin(){
|
|
const m=loadMod();if(!m)return;
|
|
document.getElementById('m-rejoin-code').textContent=m.id;
|
|
openModal('m-rejoin');
|
|
}
|
|
function doRejoin(){
|
|
const m=loadMod();if(!m)return;
|
|
closeModal('m-rejoin');
|
|
connect(()=>ws_send({type:'mod_rejoin',roomId:m.id,modSecret:m.s}));
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// SETUP PAGE
|
|
// ══════════════════════════════════════════════════════
|
|
function segSelect(groupId,el){
|
|
document.querySelectorAll('#'+groupId+' .seg-opt').forEach(b=>b.classList.remove('active'));
|
|
el.classList.add('active');
|
|
if(groupId==='seg-mode'){
|
|
const isTeams=el.dataset.v==='teams';
|
|
const teamOpts=document.getElementById('team-opts');
|
|
if(isTeams){
|
|
teamOpts.style.display='flex';
|
|
teamOpts.style.flexDirection='column';
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.fromTo(teamOpts,{opacity:0,y:-8},{opacity:1,y:0,duration:0.3,ease:'power2.out'});
|
|
}
|
|
} else {
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.to(teamOpts,{opacity:0,y:-8,duration:0.2,ease:'power2.in',onComplete:()=>{
|
|
teamOpts.style.display='none';
|
|
teamOpts.style.opacity='1';
|
|
}});
|
|
} else {
|
|
teamOpts.style.display='none';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderSetupTeamNames(){
|
|
const n=Math.max(2,Math.min(64,parseInt(document.getElementById('st-numteams').value)||2));
|
|
const container=document.getElementById('setup-team-names');
|
|
const existing=Array.from(container.querySelectorAll('input')).map(i=>i.value);
|
|
container.innerHTML='';
|
|
for(let i=0;i<n;i++){
|
|
const row=document.createElement('div');
|
|
row.style.cssText='display:flex;align-items:center;gap:10px;';
|
|
const dot=document.createElement('div');
|
|
const c=teamColor(i);
|
|
dot.style.cssText=`width:12px;height:12px;border-radius:50%;background:${c};box-shadow:0 0 7px ${c};flex-shrink:0;`;
|
|
const inp=document.createElement('input');
|
|
inp.type='text';inp.maxLength=32;
|
|
inp.value=existing[i]||greekName(i);
|
|
inp.style.flex='1';
|
|
row.appendChild(dot);row.appendChild(inp);
|
|
container.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function toggleTimerField(){
|
|
const field=document.getElementById('timer-field');
|
|
const show=document.getElementById('st-usetimer').checked;
|
|
if(show){
|
|
field.style.display='block';
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.fromTo(field,{opacity:0,y:-6},{opacity:1,y:0,duration:0.25,ease:'power2.out'});
|
|
}
|
|
} else {
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.to(field,{opacity:0,duration:0.2,onComplete:()=>{field.style.display='none';field.style.opacity='1';}});
|
|
} else {
|
|
field.style.display='none';
|
|
}
|
|
}
|
|
}
|
|
|
|
function createRoom(){
|
|
const mode=document.querySelector('#seg-mode .seg-opt.active')?.dataset.v||'individual';
|
|
const numTeams=Math.max(2,Math.min(64,parseInt(document.getElementById('st-numteams').value)||2));
|
|
const teamNames=Array.from(document.querySelectorAll('#setup-team-names input')).map(i=>i.value.trim()||greekName(0));
|
|
const useTimer=document.getElementById('st-usetimer').checked;
|
|
const timerSec=parseInt(document.getElementById('st-timersec').value)||30;
|
|
connect(()=>ws_send({
|
|
type:'create_room',
|
|
settings:{
|
|
mode,numTeams,teamNames,
|
|
playerPickTeam:document.getElementById('st-playerpick').checked,
|
|
buzzerLockout:document.getElementById('st-lockout').checked,
|
|
showBuzzOrder:document.getElementById('st-showorder').checked,
|
|
timerSeconds:useTimer?timerSec:0,
|
|
}
|
|
}));
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// MOD TIMER
|
|
// ══════════════════════════════════════════════════════
|
|
function fmtTime(s){return Math.floor(s/60)+':'+(s%60<10?'0':'')+(s%60);}
|
|
function modTimerLoad(){
|
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
|
modTimerRunning=false;
|
|
clearInterval(modTimerInterval);
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
renderModTimerDisplay();
|
|
}
|
|
function modTimerReset(){
|
|
clearInterval(modTimerInterval);
|
|
modTimerRunning=false;
|
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
renderModTimerDisplay();
|
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
|
}
|
|
function modTimerToggle(){
|
|
if(modTimerRunning){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
|
} else {
|
|
if(modTimerRemaining<=0)modTimerLoad();
|
|
modTimerRunning=true;
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
modTimerInterval=setInterval(()=>{
|
|
modTimerRemaining--;
|
|
renderModTimerDisplay();
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
if(modTimerRemaining<=0){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
ws_send({type:'close_round'});
|
|
toast('TIME UP — round closed','warn');
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.to('#mod-timer-disp',{scale:1.08,duration:0.1,yoyo:true,repeat:3,ease:'power2.inOut'});
|
|
}
|
|
}
|
|
},1000);
|
|
}
|
|
}
|
|
|
|
function toggleTimer(){
|
|
modTimerToggle();
|
|
}
|
|
|
|
function modTimerToggle(){
|
|
if(modTimerRunning){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
// Don't auto-uncheck - user controls toggle
|
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
|
} else {
|
|
if(modTimerRemaining<=0)modTimerLoad();
|
|
modTimerRunning=true;
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
// Don't auto-check - user controls toggle
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
modTimerInterval=setInterval(()=>{
|
|
modTimerRemaining--;
|
|
renderModTimerDisplay();
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
if(modTimerRemaining<=0){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
ws_send({type:'close_round'});
|
|
toast('TIME UP — round closed','warn');
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.to('#mod-timer-disp',{scale:1.08,duration:0.1,yoyo:true,repeat:3,ease:'power2.inOut'});
|
|
}
|
|
}
|
|
},1000);
|
|
}
|
|
// Sync toggle with running state
|
|
const tog=document.getElementById('timer-tog');
|
|
if(tog)tog.checked=modTimerRunning;
|
|
}
|
|
|
|
function toggleTimerFromSwitch(){
|
|
modTimerToggle();
|
|
}
|
|
|
|
function renderModTimerDisplay(){
|
|
const el=document.getElementById('mod-timer-disp');
|
|
const s=modTimerRemaining;
|
|
el.textContent=fmtTime(s);
|
|
el.className='timer-digits'+(s<=5?' danger':s<=10?' warn':'');
|
|
// Sync toggle with running state (user controls ON/OFF)
|
|
const tog=document.getElementById('timer-tog');
|
|
if(tog)tog.checked=modTimerRunning;
|
|
}
|
|
|
|
function modTimerLoad(){
|
|
const sec=parseInt(document.getElementById('mod-timer-set').value)||30;
|
|
modTimerRemaining=sec;
|
|
modTimerRunning=false;
|
|
clearInterval(modTimerInterval);
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
// Sync toggle with running state (user controls ON/OFF)
|
|
const tog=document.getElementById('timer-tog');
|
|
if(tog)tog.checked=modTimerRunning;
|
|
renderModTimerDisplay();
|
|
}
|
|
|
|
function modTimerReset(){
|
|
clearInterval(modTimerInterval);
|
|
modTimerRunning=false;
|
|
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
|
|
// Sync toggle with running state (user controls ON/OFF)
|
|
const tog=document.getElementById('timer-tog');
|
|
if(tog)tog.checked=modTimerRunning;
|
|
renderModTimerDisplay();
|
|
broadcastTimerToPlayers(modTimerRemaining,false);
|
|
}
|
|
|
|
function broadcastTimerToPlayers(sec,running){
|
|
try{
|
|
const bc=new BroadcastChannel('buzzer_timer');
|
|
bc.postMessage({sec,running});
|
|
bc.close();
|
|
}catch{}
|
|
}
|
|
|
|
// Timer toggle: user clicks ONCE at start to enable/disable linking
|
|
document.getElementById('timer-tog').addEventListener('change',function(e){
|
|
const tog=e.target;
|
|
if(tog.checked){
|
|
// Enable linking - timer auto-syncs with round buttons
|
|
} else {
|
|
// Disable linking - timer buttons work independently
|
|
}
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// MOD ROUND CONTROL
|
|
// ══════════════════════════════════════════════════════
|
|
function toggleRound(){
|
|
if(room.buzzerState.roundOpen){
|
|
ws_send({type:'close_round'});
|
|
// If linked, pause timer
|
|
if(document.getElementById('timer-tog').checked && modTimerRunning){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
}
|
|
} else {
|
|
ws_send({type:'open_round'});
|
|
// If linked and timer was stopped, start it
|
|
if(document.getElementById('timer-tog').checked && !modTimerRunning && modTimerRemaining===0){
|
|
modTimerLoad();
|
|
modTimerRunning=true;
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
modTimerInterval=setInterval(()=>{
|
|
modTimerRemaining--;
|
|
renderModTimerDisplay();
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
if(modTimerRemaining<=0){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
ws_send({type:'close_round'});
|
|
toast('TIME UP — round closed','warn');
|
|
}
|
|
},1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resumeRound(){
|
|
ws_send({type:'resume_round'});
|
|
// If linked and timer was stopped, resume it
|
|
if(document.getElementById('timer-tog').checked && !modTimerRunning){
|
|
modTimerLoad();
|
|
modTimerRunning=true;
|
|
document.getElementById('btn-timer-ss').textContent='PAUSE';
|
|
modTimerInterval=setInterval(()=>{
|
|
modTimerRemaining--;
|
|
renderModTimerDisplay();
|
|
broadcastTimerToPlayers(modTimerRemaining,true);
|
|
if(modTimerRemaining<=0){
|
|
clearInterval(modTimerInterval);modTimerRunning=false;
|
|
document.getElementById('btn-timer-ss').textContent='START';
|
|
ws_send({type:'close_round'});
|
|
toast('TIME UP — round closed','warn');
|
|
}
|
|
},1000);
|
|
}
|
|
}
|
|
|
|
function renderRoundButtons(){
|
|
const btn=document.getElementById('mod-round-btn');
|
|
const resumeBtn=document.getElementById('mod-resume-btn');
|
|
|
|
if(room.buzzerState.roundOpen){
|
|
btn.innerHTML='■ PAUSE ROUND';
|
|
btn.className='btn btn-red btn-full';
|
|
resumeBtn.style.display='none';
|
|
} else {
|
|
btn.innerHTML='▶ OPEN ROUND';
|
|
btn.className='btn btn-g btn-full';
|
|
resumeBtn.style.display='block';
|
|
}
|
|
|
|
// Sync timer toggle with running state (user controls ON/OFF)
|
|
const tog=document.getElementById('timer-tog');
|
|
if(tog)tog.checked=modTimerRunning;
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// MOD RENDER
|
|
// ══════════════════════════════════════════════════════
|
|
function renderMod(){
|
|
if(!room)return;
|
|
document.getElementById('mod-code').textContent=room.id;
|
|
document.getElementById('mod-pcount').textContent=room.players.length+' PLAYER'+(room.players.length!==1?'S':'');
|
|
document.getElementById('mod-rstatus').textContent='ROUND: '+(room.buzzerState.roundOpen?'OPEN':'CLOSED');
|
|
document.getElementById('lock-room-tog').checked=room.locked;
|
|
document.getElementById('lock-teams-tog').checked=room.teamLocked;
|
|
document.getElementById('pcount-badge').textContent=room.players.length;
|
|
document.getElementById('lock-teams-row').style.display=room.settings.mode==='teams'?'flex':'none';
|
|
document.getElementById('tab-teams-btn').style.display=room.settings.mode==='teams'?'block':'none';
|
|
if(!modTimerRunning&&room.settings.timerSeconds>0&&modTimerRemaining===0){
|
|
modTimerRemaining=room.settings.timerSeconds;
|
|
document.getElementById('mod-timer-set').value=room.settings.timerSeconds;
|
|
renderModTimerDisplay();
|
|
}
|
|
renderModBuzz(null);
|
|
renderModPlayerList();
|
|
renderModTeams();
|
|
renderRoundButtons();
|
|
// Sync timer toggle with running state (user controls ON/OFF, toggle reflects state)
|
|
const tog=document.getElementById('timer-tog');
|
|
if(tog)tog.checked=modTimerRunning;
|
|
}
|
|
|
|
function renderModBuzz(evt){
|
|
const order=room?.buzzerState?.buzzOrder??[];
|
|
const emptyEl=document.getElementById('mod-bz-empty');
|
|
const listEl=document.getElementById('mod-bz-list');
|
|
if(order.length===0){emptyEl.style.display='block';listEl.innerHTML='';return;}
|
|
emptyEl.style.display='none';
|
|
listEl.innerHTML='';
|
|
const times=evt?.buzzTimes??{};
|
|
const firstTime=times[order[0]];
|
|
order.forEach((pid,idx)=>{
|
|
const p=room.players.find(x=>x.id===pid);if(!p)return;
|
|
const teamName=(room.settings.mode==='teams'&&p.teamIndex!==null)?(room.settings.teamNames[p.teamIndex]??''):null;
|
|
const color=p.teamIndex!==null?teamColor(p.teamIndex):'var(--g)';
|
|
const ms=times[pid]&&firstTime&&idx>0?'+'+(times[pid]-firstTime)+'ms':'';
|
|
const div=document.createElement('div');
|
|
div.className='bz-entry';
|
|
div.innerHTML=`
|
|
<div class="bz-rank ${idx===0?'first':''}">${idx+1}</div>
|
|
<div class="bz-info">
|
|
<div class="bz-name">${pid}</div>
|
|
${teamName?`<div class="bz-team" style="color:${color}">${esc(teamName)}</div>`:''}
|
|
${ms?`<div class="bz-ms">${ms} after first</div>`:''}
|
|
</div>
|
|
`;
|
|
listEl.appendChild(div);
|
|
// GSAP entrance for new entries
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.fromTo(div,
|
|
{opacity:0,x:-24},
|
|
{opacity:1,x:0,duration:0.4,ease:'power3.out',delay:idx*0.05}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderModPlayerList(){
|
|
if(!room)return;
|
|
const el=document.getElementById('mod-plist');
|
|
if(room.players.length===0){el.innerHTML='<div class="empty">No players yet.</div>';return;}
|
|
el.innerHTML='';
|
|
room.players.forEach(p=>{
|
|
const teamName=(room.settings.mode==='teams'&&p.teamIndex!==null)?(room.settings.teamNames[p.teamIndex]??''):null;
|
|
const color=p.teamIndex!==null?teamColor(p.teamIndex):null;
|
|
let teamSel='';
|
|
if(room.settings.mode==='teams'){
|
|
let opts=`<option value="-1" ${p.teamIndex===null?'selected':''}>No team</option>`;
|
|
for(let i=0;i<room.settings.numTeams;i++){
|
|
const tn=room.settings.teamNames[i]??greekName(i);
|
|
opts+=`<option value="${i}" ${p.teamIndex===i?'selected':''}>${esc(tn)}</option>`;
|
|
}
|
|
teamSel=`<select style="margin-top:8px;font-size:12px;padding:6px 10px;" onchange="ws_send({type:'assign_team',playerId:'${p.id}',teamIndex:+this.value===-1?null:+this.value})">${opts}</select>`;
|
|
}
|
|
const row=document.createElement('div');
|
|
row.className='pl-row'+(p.isConnected?'':' offline');
|
|
row.innerHTML=`
|
|
<div class="pl-info" style="flex:1;min-width:0;">
|
|
<div class="pl-name">${p.id} ${p.isConnected?'':`<span class="tag tag-red" style="font-size:9px;padding:2px 6px;">OFFLINE</span>`}</div>
|
|
${teamName?`<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>`:'<div class="pl-meta">No team</div>'}
|
|
${teamSel}
|
|
</div>
|
|
<div class="pl-actions">
|
|
<button class="btn btn-red btn-sm" onclick="ws_send({type:'kick_player',playerId:'${p.id}'})">KICK</button>
|
|
</div>
|
|
`;
|
|
el.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function renderModTeams(){
|
|
if(!room||room.settings.mode!=='teams')return;
|
|
const grid=document.getElementById('mod-team-grid');
|
|
grid.innerHTML='';
|
|
for(let i=0;i<room.settings.numTeams;i++){
|
|
const members=room.players.filter(p=>p.teamIndex===i);
|
|
const color=teamColor(i);
|
|
const name=room.settings.teamNames[i]??greekName(i);
|
|
const card=document.createElement('div');
|
|
card.className='team-card';
|
|
card.style.borderColor=color;
|
|
card.innerHTML=`
|
|
<div class="tc-n" style="color:${color}">${esc(name)}</div>
|
|
<div class="tc-c" style="color:${color}">${members.length}</div>
|
|
<div class="tc-m">${members.map(p=>p.id).join('<br>')||'—'}</div>
|
|
`;
|
|
grid.appendChild(card);
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.fromTo(card,{opacity:0,scale:0.92},{opacity:1,scale:1,duration:0.35,ease:'back.out(1.2)',delay:i*0.04});
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderModSettings(){
|
|
if(!room)return;
|
|
const s=room.settings;
|
|
document.getElementById('ls-lockout').checked=s.buzzerLockout;
|
|
document.getElementById('ls-showorder').checked=s.showBuzzOrder;
|
|
document.getElementById('ls-playerpick').checked=s.playerPickTeam;
|
|
document.getElementById('ls-numteams').value=s.numTeams;
|
|
segActivate('ls-seg-mode',s.mode);
|
|
renderLiveTeamNames();
|
|
renderRoundButtons();
|
|
renderModTimerDisplay();
|
|
}
|
|
|
|
function segActivate(groupId,val){
|
|
document.querySelectorAll('#'+groupId+' .seg-opt').forEach(b=>b.classList.toggle('active',b.dataset.v===val));
|
|
}
|
|
|
|
function renderLiveTeamNames(){
|
|
if(!room)return;
|
|
const container=document.getElementById('ls-team-names');
|
|
container.innerHTML='';
|
|
for(let i=0;i<room.settings.numTeams;i++){
|
|
const row=document.createElement('div');
|
|
row.style.cssText='display:flex;align-items:center;gap:10px;';
|
|
const dot=document.createElement('div');
|
|
const c=teamColor(i);
|
|
dot.style.cssText=`width:12px;height:12px;border-radius:50%;background:${c};flex-shrink:0;`;
|
|
const inp=document.createElement('input');
|
|
inp.type='text';inp.maxLength=32;
|
|
inp.value=room.settings.teamNames[i]??greekName(i);
|
|
inp.onchange=()=>updateLiveTeamName(i,inp.value);
|
|
row.appendChild(dot);row.appendChild(inp);
|
|
container.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function updateLiveTeamName(idx,val){
|
|
if(!room)return;
|
|
const names=[...room.settings.teamNames];
|
|
names[idx]=val.trim()||greekName(idx);
|
|
ws_send({type:'update_settings',settings:{teamNames:names}});
|
|
}
|
|
function pushSetting(key,val){ws_send({type:'update_settings',settings:{[key]:val}});}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// PLAYER TIMER (receives from BroadcastChannel)
|
|
// ══════════════════════════════════════════════════════
|
|
let _bc=null;
|
|
function initPlayerTimer(){
|
|
try{
|
|
_bc=new BroadcastChannel('buzzer_timer');
|
|
_bc.onmessage=e=>{
|
|
const{sec,running}=e.data;
|
|
playerTimerRemaining=sec;
|
|
clearInterval(playerTimerInterval);
|
|
renderPlayerTimer();
|
|
if(running&&sec>0){
|
|
playerTimerInterval=setInterval(()=>{
|
|
playerTimerRemaining--;
|
|
renderPlayerTimer();
|
|
if(playerTimerRemaining<=0)clearInterval(playerTimerInterval);
|
|
},1000);
|
|
}
|
|
};
|
|
}catch{}
|
|
}
|
|
function stopPlayerTimer(){clearInterval(playerTimerInterval);}
|
|
function startPlayerTimer(){}
|
|
function renderPlayerTimer(){
|
|
const el=document.getElementById('p-timer');
|
|
const s=playerTimerRemaining;
|
|
if(s<=0||!room?.settings.timerSeconds){el.className='p-timer hidden';return;}
|
|
el.textContent=fmtTime(s);
|
|
el.className='p-timer'+(s<=5?' danger':s<=10?' warn':'');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// PLAYER RENDER
|
|
// ══════════════════════════════════════════════════════
|
|
function renderPlayer(){
|
|
if(!room)return;
|
|
document.getElementById('p-code').textContent=room.id;
|
|
const me=room.players.find(p=>p.id===myId);
|
|
document.getElementById('p-namelbl').textContent=me?.id||'';
|
|
renderTeamPicker();
|
|
renderPlayerBuzzer();
|
|
renderRoster();
|
|
}
|
|
|
|
function renderTeamPicker(){
|
|
if(!room)return;
|
|
const s=room.settings;
|
|
const picker=document.getElementById('p-team-picker');
|
|
if(s.mode!=='teams'||!s.playerPickTeam||room.teamLocked){picker.style.display='none';return;}
|
|
picker.style.display='block';
|
|
const me=room.players.find(p=>p.id===myId);
|
|
const grid=document.getElementById('p-team-grid');
|
|
grid.innerHTML='';
|
|
for(let i=0;i<s.numTeams;i++){
|
|
const members=room.players.filter(p=>p.teamIndex===i);
|
|
const color=teamColor(i);
|
|
const isMine=me?.teamIndex===i;
|
|
const btn=document.createElement('button');
|
|
btn.className='team-btn'+(isMine?' mine':'');
|
|
btn.style.borderColor=isMine?color:'var(--border2)';
|
|
btn.style.color=isMine?color:'var(--text)';
|
|
btn.style.padding='16px';
|
|
btn.style.fontSize='20px';
|
|
btn.style.fontWeight='600';
|
|
btn.style.cursor='pointer';
|
|
btn.innerHTML=`<div style="font-size:24px;">${esc(s.teamNames[i]??greekName(i))}</div><div style="font-size:14px;color:var(--dim);margin-top:4px;">${members.length} player${members.length!==1?'s':''}</div>`;
|
|
btn.onclick=()=>ws_send({type:'pick_team',teamIndex:i});
|
|
grid.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
function renderPlayerBuzzer(){
|
|
if(!room)return;
|
|
const btn=document.getElementById('buzz-btn');
|
|
const sts=document.getElementById('buzz-status');
|
|
const bz=room.buzzerState;
|
|
const already=bz.buzzOrder.includes(myId);
|
|
const isFirst=bz.buzzOrder[0]===myId;
|
|
|
|
const hint=document.getElementById('spacebar-hint');
|
|
let newState='';
|
|
if(!bz.roundOpen){
|
|
newState='closed';
|
|
btn.className='s-closed';btn.disabled=true;sts.textContent='WAITING FOR ROUND';
|
|
if(hint)hint.classList.remove('visible');
|
|
} else if(isFirst){
|
|
newState='first';
|
|
btn.className='s-first';btn.disabled=false;sts.textContent='YOU BUZZED FIRST!';
|
|
if(hint)hint.classList.remove('visible');
|
|
} else if(already){
|
|
const pos=bz.buzzOrder.indexOf(myId)+1;
|
|
newState='buzzed';
|
|
btn.className='s-buzzed';btn.disabled=true;sts.textContent='BUZZED — #'+pos+' IN ORDER';
|
|
if(hint)hint.classList.remove('visible');
|
|
} else if(room.settings.buzzerLockout&&bz.buzzOrder.length>0){
|
|
newState='locked';
|
|
btn.className='s-locked';btn.disabled=true;sts.textContent='BUZZER LOCKED OUT';
|
|
if(hint)hint.classList.remove('visible');
|
|
} else {
|
|
newState='open';
|
|
btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!';
|
|
if(hint)hint.classList.add('visible');
|
|
}
|
|
|
|
_lastBuzzState=newState;
|
|
}
|
|
|
|
function renderRoster(){
|
|
if(!room)return;
|
|
const el=document.getElementById('p-roster');
|
|
if(room.players.length===0){el.innerHTML='<div style="font-size:18px;color:var(--dim);padding:20px;">No players yet.</div>';return;}
|
|
el.innerHTML='';
|
|
room.players.forEach(p=>{
|
|
const isMe=p.id===myId;
|
|
const color=p.teamIndex!==null?teamColor(p.teamIndex):'var(--g)';
|
|
const teamName=(room.settings.mode==='teams'&&p.teamIndex!==null)?(room.settings.teamNames[p.teamIndex]??''):'';
|
|
const row=document.createElement('div');
|
|
row.className='roster-row'+(isMe?' roster-me':'');
|
|
row.innerHTML=`
|
|
<div class="roster-dot" style="width:16px;height:16px;border-radius:50%;background:${p.isConnected?(isMe?'var(--g)':color):'var(--border2)'}"></div>
|
|
<div style="font-size:20px;font-weight:700;">#${p.id}</div>
|
|
${teamName?`<div style="font-size:14px;color:${color};margin-top:2px;">${esc(teamName)}</div>`:''}
|
|
`;
|
|
el.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function addFeed(evt){
|
|
const feed=document.getElementById('p-feed');
|
|
const isFirst=evt.buzzOrder?.[0]===evt.playerId;
|
|
const color=evt.teamIndex!==null?teamColor(evt.teamIndex):'var(--g)';
|
|
const teamStr=(room?.settings.mode==='teams'&&evt.teamIndex!==null)?` [${esc(room.settings.teamNames[evt.teamIndex]??'')}]`:'';
|
|
const div=document.createElement('div');
|
|
div.className='feed-entry'+(isFirst?' first':'');
|
|
div.style.borderLeftColor=isFirst?'var(--yellow)':color;
|
|
div.innerHTML=`<strong>#${evt.playerId}</strong>${teamStr} buzzed${isFirst?' <span style="color:var(--yellow);font-weight:700;"> — FIRST!</span>':''}`;
|
|
feed.prepend(div);
|
|
while(feed.children.length>30)feed.removeChild(feed.lastChild);
|
|
}
|
|
|
|
function doBuzz(){
|
|
ws_send({type:'buzz'});
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// TABS
|
|
// ══════════════════════════════════════════════════════
|
|
function setTab(name,el){
|
|
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));
|
|
el.classList.add('on');
|
|
['buzzer','players','teams','settings'].forEach(t=>{
|
|
const te=document.getElementById('tab-'+t);
|
|
if(te)te.style.display=t===name?'block':'none';
|
|
});
|
|
if(name==='players')renderModPlayerList();
|
|
if(name==='teams')renderModTeams();
|
|
if(name==='settings')renderModSettings();
|
|
// Animate tab content
|
|
const activeTab=document.getElementById('tab-'+name);
|
|
if(activeTab&&typeof gsap!=='undefined'){
|
|
gsap.fromTo(activeTab,{opacity:0,y:8},{opacity:1,y:0,duration:0.3,ease:'power2.out'});
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// MODALS / TOAST / UTIL
|
|
// ══════════════════════════════════════════════════════
|
|
function openModal(id){
|
|
const bg=document.getElementById(id);
|
|
bg.classList.add('on');
|
|
if(typeof gsap!=='undefined'){
|
|
const modal=bg.querySelector('.modal');
|
|
gsap.fromTo(bg,{opacity:0},{opacity:1,duration:0.25,ease:'power2.out'});
|
|
gsap.fromTo(modal,{scale:0.88,y:20,opacity:0},{scale:1,y:0,opacity:1,duration:0.35,ease:'back.out(1.5)'});
|
|
}
|
|
}
|
|
function closeModal(id){
|
|
const bg=document.getElementById(id);
|
|
if(typeof gsap!=='undefined'){
|
|
const modal=bg.querySelector('.modal');
|
|
gsap.to(modal,{scale:0.92,opacity:0,duration:0.2,ease:'power2.in'});
|
|
gsap.to(bg,{opacity:0,duration:0.25,ease:'power2.in',onComplete:()=>bg.classList.remove('on')});
|
|
} else {
|
|
bg.classList.remove('on');
|
|
}
|
|
}
|
|
function copyCode(){
|
|
if(!room)return;
|
|
navigator.clipboard.writeText(room.id).then(()=>toast('Code copied','ok'));
|
|
}
|
|
function toast(msg,type=''){
|
|
const el=document.createElement('div');
|
|
el.className='toast '+type;el.textContent=msg;
|
|
document.getElementById('toasts').appendChild(el);
|
|
if(typeof gsap!=='undefined'){
|
|
gsap.fromTo(el,
|
|
{opacity:0,x:20},
|
|
{opacity:1,x:0,duration:0.3,ease:'power3.out'}
|
|
);
|
|
setTimeout(()=>{
|
|
gsap.to(el,{opacity:0,x:12,duration:0.3,ease:'power2.in',onComplete:()=>el.remove()});
|
|
},2700);
|
|
} else {
|
|
setTimeout(()=>{ el.style.transition='opacity .3s';el.style.opacity='0';setTimeout(()=>el.remove(),300);},2700);
|
|
}
|
|
}
|
|
function esc(s){
|
|
return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// KEYBOARD — SPACE TO BUZZ
|
|
// ══════════════════════════════════════════════════════
|
|
document.addEventListener('keydown',e=>{
|
|
if(role!=='player')return;
|
|
if(e.code==='Space'||e.key===' '){
|
|
const tag=document.activeElement?.tagName;
|
|
if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return;
|
|
e.preventDefault();
|
|
const btn=document.getElementById('buzz-btn');
|
|
if(!btn.disabled){btn.click();}
|
|
}
|
|
});
|
|
|
|
// close modals on backdrop click
|
|
document.querySelectorAll('.modal-bg').forEach(bg=>{
|
|
bg.addEventListener('click',e=>{if(e.target===bg)closeModal(bg.id);});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// LANDING PAGE ENTRANCE ANIMATION
|
|
// ══════════════════════════════════════════════════════
|
|
function animateLanding(){
|
|
if(typeof gsap==='undefined')return;
|
|
const h1=document.querySelector('#s-land .hero h1');
|
|
const sub=document.querySelector('#s-land .hero p');
|
|
const badge=document.querySelector('#s-land .hero-badge');
|
|
const cards=document.querySelectorAll('#s-land .land-card');
|
|
const rejoin=document.getElementById('rejoin-bar');
|
|
|
|
const tl=gsap.timeline({delay:0.1});
|
|
tl.fromTo(h1,{opacity:0,scale:0.85,y:24},{opacity:1,scale:1,y:0,duration:0.65,ease:'back.out(1.6)'});
|
|
tl.fromTo(sub,{opacity:0,y:12},{opacity:1,y:0,duration:0.4,ease:'power3.out'},'-=0.35');
|
|
if(badge)tl.fromTo(badge,{opacity:0,y:8},{opacity:1,y:0,duration:0.35,ease:'power2.out'},'-=0.25');
|
|
tl.fromTo(cards,{opacity:0,y:30},{opacity:1,y:0,duration:0.5,stagger:0.1,ease:'power3.out'},'-=0.2');
|
|
if(rejoin&&rejoin.style.display!=='none'){
|
|
tl.fromTo(rejoin,{opacity:0},{opacity:1,duration:0.4},'-=0.1');
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// INIT
|
|
// ══════════════════════════════════════════════════════
|
|
window.addEventListener('DOMContentLoaded',()=>{
|
|
const m=loadMod();
|
|
if(m){document.getElementById('rejoin-bar').style.display='block';document.getElementById('m-rejoin-code').textContent=m.id;}
|
|
showScr('s-land');
|
|
connect(null);
|
|
initPlayerTimer();
|
|
renderSetupTeamNames();
|
|
// init seg mode display
|
|
const modeBtn=document.querySelector('#seg-mode .seg-opt.active');
|
|
if(modeBtn)segSelect('seg-mode',modeBtn);
|
|
// animate landing after GSAP loads
|
|
setTimeout(animateLanding,80);
|
|
});
|