// ══════════════════════════════════════════════════════ // 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[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 'round_open': room=msg.room; if(role==='mod'){renderMod();renderRoundButtons();} else{renderPlayerBuzzer();startPlayerTimer();toast('ROUND OPEN','ok');} 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 'round_closed': room=msg.room; if(role==='mod')renderMod(); else{renderPlayerBuzzer();stopPlayerTimer();toast('ROUND CLOSED','warn');} break; case 'buzzer_reset': room=msg.room; if(role==='mod')renderMod();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{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 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':''); } function broadcastTimerToPlayers(sec,running){ try{ const bc=new BroadcastChannel('buzzer_timer'); bc.postMessage({sec,running}); bc.close(); }catch{} } // ══════════════════════════════════════════════════════ // MOD ROUND CONTROL // ══════════════════════════════════════════════════════ function toggleRound(){ // Reset buzzer and open round ws_send({type:'open_round'}); // Change button to pause state const btn=document.getElementById('mod-round-btn'); btn.innerHTML='■ PAUSE ROUND'; btn.className='btn btn-red btn-full'; } function resumeRound(){ ws_send({type:'open_round'}); } 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'; } } // ══════════════════════════════════════════════════════ // 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(); } 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=`
${idx+1}
${pid}
${teamName?`
${esc(teamName)}
`:''} ${ms?`
${ms} after first
`:''}
`; 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='
No players yet.
';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=``; for(let i=0;i${esc(tn)}`; } teamSel=``; } const row=document.createElement('div'); row.className='pl-row'+(p.isConnected?'':' offline'); row.innerHTML=`
${p.id} ${p.isConnected?'':`OFFLINE`}
${teamName?`
${esc(teamName)}
`:'
No team
'} ${teamSel}
`; 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;ip.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=`
${esc(name)}
${members.length}
${members.map(p=>p.id).join('
')||'—'}
`; 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(); } 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;iupdateLiveTeamName(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;ip.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=`
${esc(s.teamNames[i]??greekName(i))}
${members.length} player${members.length!==1?'s':''}
`; 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='
No players yet.
';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=`
#${p.id}
${teamName?`
${esc(teamName)}
`:''} `; 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=`#${evt.playerId}${teamStr} buzzed${isFirst?' — FIRST!':''}`; 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,'"'); } // ══════════════════════════════════════════════════════ // 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); });