Files
buzzer-site/src/public/script.js
KeshavAnandCode 6afb853874 Update application aesthetics with a pastel color scheme and larger text
Introduce the Catppuccin Mocha color palette and increase font sizes for improved readability, while also adding a visual hint for spacebar functionality.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: f3ac8eb3-f610-4678-ab6e-ebf900098be4
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 098ca8d5-1a49-468d-abec-89858708710d
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/d4b7863b-f7b2-425c-a9b5-ad7bd1885e9d/f3ac8eb3-f610-4678-ab6e-ebf900098be4/N1qS6qo
Replit-Helium-Checkpoint-Created: true
2026-03-25 21:14:15 +00:00

788 lines
36 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,name)=>localStorage.setItem('play',JSON.stringify({rid,pid,name}));
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,document.getElementById('ji-name').value||loadPlay()?.name||'');
showScr('s-player');renderPlayer();
break;
case 'room_update':
room=msg.room;
if(role==='mod')renderMod();else renderPlayer();
break;
case 'settings_updated':
room=msg.room;
if(role==='mod'){renderMod();renderModSettings();}else renderPlayer();
break;
case 'round_open':
room=msg.room;
if(role==='mod')renderMod();
else{renderPlayerBuzzer();startPlayerTimer();toast('ROUND OPEN','ok');}
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')renderModBuzz(msg);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();
const name=document.getElementById('ji-name').value.trim();
if(!code){toast('Enter room code','err');return;}
if(!name){toast('Enter your name','err');return;}
connect(()=>ws_send({type:'join_room',roomId:code,playerName:name}));
}
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 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 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();
}
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">${esc(p.name)}</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">${esc(p.name)} ${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=>esc(p.name)).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();
}
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?.name??'';
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.innerHTML=`<div>${esc(s.teamNames[i]??greekName(i))}</div><div class="tb-count">${members.length} player${members.length!==1?'s':''}</div>`;
btn.onclick=()=>ws_send({type:'pick_team',teamIndex:i});
grid.appendChild(btn);
if(typeof gsap!=='undefined'){
gsap.fromTo(btn,{opacity:0,y:10},{opacity:1,y:0,duration:0.3,ease:'power2.out',delay:i*0.04});
}
}
}
let _lastBuzzState='';
function renderPlayerBuzzer(){
if(!room)return;
const btn=document.getElementById('buzz-btn');
const sts=document.getElementById('buzz-status');
const ripple=document.getElementById('buzz-ripple');
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';
ripple.style.display='none';
if(hint)hint.classList.remove('visible');
} else if(isFirst){
newState='first';
btn.className='s-first';btn.disabled=false;sts.textContent='YOU BUZZED FIRST!';
ripple.style.display='flex';
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';
ripple.style.display='none';
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';
ripple.style.display='none';
if(hint)hint.classList.remove('visible');
} else {
newState='open';
btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!';
ripple.style.display='flex';
if(hint)hint.classList.add('visible');
}
// Animate state transitions
if(newState!==_lastBuzzState && typeof gsap!=='undefined'){
if(newState==='first'){
gsap.fromTo(btn,{scale:0.88},{scale:1,duration:0.5,ease:'back.out(2.5)'});
gsap.fromTo(sts,{opacity:0,y:6},{opacity:1,y:0,duration:0.4,ease:'power2.out'});
} else if(newState==='open' && _lastBuzzState==='closed'){
gsap.fromTo(btn,{scale:0.95},{scale:1,duration:0.4,ease:'back.out(1.5)'});
} else if(newState==='closed'){
gsap.to(btn,{scale:0.97,duration:0.15,yoyo:true,repeat:1,ease:'power2.inOut'});
}
}
_lastBuzzState=newState;
}
function renderRoster(){
if(!room)return;
const el=document.getElementById('p-roster');
if(room.players.length===0){el.innerHTML='<div style="font-size:13px;color:var(--dim);padding:12px;letter-spacing:1px;">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="background:${p.isConnected?(isMe?'var(--g)':color):'var(--border2)'}"></div>
<div style="flex:1">${esc(p.name)}${isMe?' <span style="font-size:11px;color:var(--dim);letter-spacing:1px;">(YOU)</span>':''}</div>
${teamName?`<div style="font-size:12px;color:${color};letter-spacing:0.5px;">${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>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst?' <span style="color:var(--yellow);font-weight:700;"> — FIRST!</span>':''}`;
feed.prepend(div);
if(typeof gsap!=='undefined'){
gsap.fromTo(div,
{opacity:0,x:-20,borderLeftWidth:'3px'},
{opacity:1,x:0,duration:0.38,ease:'power3.out'}
);
}
while(feed.children.length>30)feed.removeChild(feed.lastChild);
}
function doBuzz(){
ws_send({type:'buzz'});
// Immediate haptic-like feedback
if(typeof gsap!=='undefined'){
const btn=document.getElementById('buzz-btn');
gsap.to(btn,{scale:0.91,duration:0.08,ease:'power2.in',onComplete:()=>{
gsap.to(btn,{scale:1,duration:0.25,ease:'back.out(2.5)'});
}});
}
}
// ══════════════════════════════════════════════════════
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ══════════════════════════════════════════════════════
// 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);
});