vibe coding is the best
This commit is contained in:
File diff suppressed because it is too large
Load Diff
625
src/public/script.js
Normal file
625
src/public/script.js
Normal file
@@ -0,0 +1,625 @@
|
||||
// ══════════════════════════════════════════════════════
|
||||
// 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; // golden angle spacing
|
||||
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
|
||||
// ══════════════════════════════════════════════════════
|
||||
function showScr(id) {
|
||||
document.querySelectorAll('.scr').forEach(s => { s.classList.remove('on'); s.style.display = 'none'; });
|
||||
const el = document.getElementById(id);
|
||||
el.style.display = 'flex'; el.classList.add('on');
|
||||
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';
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// 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';
|
||||
document.getElementById('team-opts').style.display = isTeams ? 'flex' : 'none';
|
||||
document.getElementById('team-opts').style.flexDirection = 'column';
|
||||
}
|
||||
}
|
||||
|
||||
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:8px;';
|
||||
const dot = document.createElement('div');
|
||||
const c = teamColor(i);
|
||||
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};box-shadow:0 0 6px ${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() {
|
||||
document.getElementById('timer-field').style.display = document.getElementById('st-usetimer').checked ? 'block' : '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();
|
||||
// broadcast reset to players
|
||||
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');
|
||||
}
|
||||
}, 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' : '');
|
||||
}
|
||||
|
||||
// We sync timer to players via a side-channel: store in sessionStorage and poll
|
||||
// (pure client-side sync — no extra server message needed)
|
||||
function broadcastTimerToPlayers(sec, running) {
|
||||
// encode into a BroadcastChannel so other tabs (players on same device) see it
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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:6px;font-size:12px;padding:4px 8px;" 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">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);
|
||||
}
|
||||
}
|
||||
|
||||
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:8px;';
|
||||
const dot = document.createElement('div');
|
||||
const c = teamColor(i);
|
||||
dot.style.cssText = `width:11px;height:11px;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() {
|
||||
// timer already synced via BroadcastChannel
|
||||
}
|
||||
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(--border)';
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (!bz.roundOpen) {
|
||||
btn.className = 's-closed'; btn.disabled = true; sts.textContent = 'WAITING FOR ROUND';
|
||||
} else if (isFirst) {
|
||||
btn.className = 's-first'; btn.disabled = false; sts.textContent = '⚡ YOU BUZZED FIRST!';
|
||||
} else if (already) {
|
||||
const pos = bz.buzzOrder.indexOf(myId) + 1;
|
||||
btn.className = 's-buzzed'; btn.disabled = true; sts.textContent = 'BUZZED — #' + pos + ' IN ORDER';
|
||||
} else if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) {
|
||||
btn.className = 's-locked'; btn.disabled = true; sts.textContent = 'BUZZER LOCKED OUT';
|
||||
} else {
|
||||
btn.className = 's-open'; btn.disabled = false; sts.textContent = 'ROUND OPEN — BUZZ!';
|
||||
}
|
||||
}
|
||||
|
||||
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:10px">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(--border)'}"></div>
|
||||
<div style="flex:1">${esc(p.name)}${isMe ? ' <span style="font-size:11px;color:var(--dim)">(YOU)</span>' : ''}</div>
|
||||
${teamName ? `<div style="font-size:12px;color:${color}">${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.borderColor = isFirst ? 'var(--yellow)' : color;
|
||||
div.innerHTML = `<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst ? ' <span style="color:var(--yellow)">— 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();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// MODALS / TOAST / UTIL
|
||||
// ══════════════════════════════════════════════════════
|
||||
function openModal(id) { document.getElementById(id).classList.add('on'); }
|
||||
function closeModal(id) { document.getElementById(id).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);
|
||||
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(); btn.style.transform = 'scale(.93)'; setTimeout(() => btn.style.transform = '', 120); }
|
||||
}
|
||||
});
|
||||
|
||||
// close modals on backdrop click
|
||||
document.querySelectorAll('.modal-bg').forEach(bg => {
|
||||
bg.addEventListener('click', e => { if (e.target === bg) bg.classList.remove('on'); });
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// 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);
|
||||
});
|
||||
1215
src/public/styles.css
Normal file
1215
src/public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
39
src/rooms.ts
39
src/rooms.ts
@@ -4,10 +4,10 @@ export interface RoomSettings {
|
||||
mode: "individual" | "teams";
|
||||
numTeams: number;
|
||||
teamNames: string[];
|
||||
playerPickTeam: boolean; // players can self-assign teams
|
||||
showBuzzOrder: boolean; // show full order vs first only
|
||||
buzzerLockout: boolean; // lock after first buzz
|
||||
timerSeconds: number; // 0 = no timer
|
||||
playerPickTeam: boolean;
|
||||
showBuzzOrder: boolean;
|
||||
buzzerLockout: boolean;
|
||||
timerSeconds: number;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
@@ -31,13 +31,28 @@ export interface Room {
|
||||
settings: RoomSettings;
|
||||
players: Map<string, Player>;
|
||||
buzzerState: BuzzerState;
|
||||
locked: boolean; // room locked to new joins
|
||||
teamLocked: boolean; // team selection locked
|
||||
locked: boolean;
|
||||
teamLocked: boolean;
|
||||
}
|
||||
|
||||
export const rooms = new Map<string, Room>();
|
||||
export const rooms = new Map<string, Room>();
|
||||
export const wsToPlayer = new Map<ServerWebSocket<unknown>, { roomId: string; playerId: string }>();
|
||||
|
||||
// Infinite Greek alphabet: Alpha, Beta, ... Omega, Alpha II, Beta II, ...
|
||||
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"];
|
||||
export function greekName(i: number): string {
|
||||
const cycle = Math.floor(i / GREEK.length);
|
||||
const name = GREEK[i % GREEK.length];
|
||||
return cycle === 0 ? name : `${name} ${toRoman(cycle + 1)}`;
|
||||
}
|
||||
function toRoman(n: number): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export function genId(len = 8): string {
|
||||
const c = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
let s = "";
|
||||
@@ -55,11 +70,11 @@ export function freshBuzzer(): BuzzerState {
|
||||
|
||||
export function publicRoom(room: Room) {
|
||||
return {
|
||||
id: room.id,
|
||||
settings: room.settings,
|
||||
locked: room.locked,
|
||||
teamLocked: room.teamLocked,
|
||||
modOnline: room.modWs !== null,
|
||||
id: room.id,
|
||||
settings: room.settings,
|
||||
locked: room.locked,
|
||||
teamLocked: room.teamLocked,
|
||||
modOnline: room.modWs !== null,
|
||||
buzzerState: {
|
||||
roundOpen: room.buzzerState.roundOpen,
|
||||
buzzOrder: room.buzzerState.buzzOrder,
|
||||
|
||||
@@ -2,14 +2,19 @@ import { readFileSync } from "fs";
|
||||
import { handleMessage, handleClose } from "./ws-handler";
|
||||
|
||||
const HTML = readFileSync("./src/public/index.html", "utf-8");
|
||||
const CSS = readFileSync("./src/public/styles.css", "utf-8");
|
||||
const JS = readFileSync("./src/public/script.js", "utf-8");
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
fetch(req, server) {
|
||||
if (new URL(req.url).pathname === "/ws") {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/ws") {
|
||||
if (!server.upgrade(req)) return new Response("WS upgrade failed", { status: 400 });
|
||||
return undefined as any;
|
||||
}
|
||||
if (url.pathname === "/styles.css") return new Response(CSS, { headers: { "Content-Type": "text/css" } });
|
||||
if (url.pathname === "/script.js") return new Response(JS, { headers: { "Content-Type": "text/javascript" } });
|
||||
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||
},
|
||||
websocket: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ServerWebSocket } from "bun";
|
||||
import {
|
||||
rooms, wsToPlayer,
|
||||
Room, Player,
|
||||
genId, sanitize, freshBuzzer, publicRoom, broadcast, toMod,
|
||||
genId, greekName, sanitize, freshBuzzer, publicRoom, broadcast, toMod,
|
||||
} from "./rooms";
|
||||
|
||||
type WS = ServerWebSocket<unknown>;
|
||||
@@ -18,11 +18,10 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
/* ── CREATE ROOM ── */
|
||||
if (type === "create_room") {
|
||||
const s = msg.settings ?? {};
|
||||
const numTeams = Math.min(8, Math.max(2, (s.numTeams ?? 2) | 0));
|
||||
const defaultNames = ["ALPHA","BETA","GAMMA","DELTA","EPSILON","ZETA","ETA","THETA"];
|
||||
const numTeams = Math.max(2, Math.min(64, (s.numTeams ?? 2) | 0));
|
||||
const teamNames: string[] = [];
|
||||
for (let i = 0; i < numTeams; i++)
|
||||
teamNames.push(sanitize(s.teamNames?.[i] ?? defaultNames[i], 32));
|
||||
teamNames.push(s.teamNames?.[i] ? sanitize(s.teamNames[i], 32) : greekName(i));
|
||||
|
||||
const room: Room = {
|
||||
id: genId(6),
|
||||
@@ -35,7 +34,7 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
playerPickTeam: s.playerPickTeam === true,
|
||||
showBuzzOrder: s.showBuzzOrder !== false,
|
||||
buzzerLockout: s.buzzerLockout !== false,
|
||||
timerSeconds: Math.min(300, Math.max(0, (s.timerSeconds ?? 0) | 0)),
|
||||
timerSeconds: Math.min(600, Math.max(0, (s.timerSeconds ?? 0) | 0)),
|
||||
},
|
||||
players: new Map(),
|
||||
buzzerState: freshBuzzer(),
|
||||
@@ -84,7 +83,7 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* ── PICK TEAM (player) ── */
|
||||
/* ── PICK TEAM ── */
|
||||
if (type === "pick_team") {
|
||||
const ctx = wsToPlayer.get(ws);
|
||||
if (!ctx) return;
|
||||
@@ -109,15 +108,9 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
if (!room) return;
|
||||
const bz = room.buzzerState;
|
||||
|
||||
if (!bz.roundOpen) {
|
||||
tx(ws, { type: "buzz_rejected", reason: "Round not open" }); return;
|
||||
}
|
||||
if (bz.buzzOrder.includes(ctx.playerId)) {
|
||||
tx(ws, { type: "buzz_rejected", reason: "Already buzzed" }); return;
|
||||
}
|
||||
if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) {
|
||||
tx(ws, { type: "buzz_rejected", reason: "Locked out" }); return;
|
||||
}
|
||||
if (!bz.roundOpen) { tx(ws, { type: "buzz_rejected", reason: "Round not open" }); return; }
|
||||
if (bz.buzzOrder.includes(ctx.playerId)) { tx(ws, { type: "buzz_rejected", reason: "Already buzzed" }); return; }
|
||||
if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) { tx(ws, { type: "buzz_rejected", reason: "Locked out" }); return; }
|
||||
|
||||
bz.buzzOrder.push(ctx.playerId);
|
||||
bz.buzzTimes.set(ctx.playerId, Date.now());
|
||||
@@ -125,23 +118,8 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
const p = room.players.get(ctx.playerId)!;
|
||||
const pubOrder = room.settings.showBuzzOrder ? bz.buzzOrder : [bz.buzzOrder[0]];
|
||||
|
||||
broadcast(room, {
|
||||
type: "buzz_event",
|
||||
playerId: ctx.playerId,
|
||||
playerName: p.name,
|
||||
teamIndex: p.teamIndex,
|
||||
buzzOrder: pubOrder,
|
||||
room: publicRoom(room),
|
||||
});
|
||||
toMod(room, {
|
||||
type: "buzz_event",
|
||||
playerId: ctx.playerId,
|
||||
playerName: p.name,
|
||||
teamIndex: p.teamIndex,
|
||||
buzzOrder: bz.buzzOrder,
|
||||
buzzTimes: Object.fromEntries(bz.buzzTimes),
|
||||
room: publicRoom(room),
|
||||
});
|
||||
broadcast(room, { type: "buzz_event", playerId: ctx.playerId, playerName: p.name, teamIndex: p.teamIndex, buzzOrder: pubOrder, room: publicRoom(room) });
|
||||
toMod(room, { type: "buzz_event", playerId: ctx.playerId, playerName: p.name, teamIndex: p.teamIndex, buzzOrder: bz.buzzOrder, buzzTimes: Object.fromEntries(bz.buzzTimes), room: publicRoom(room) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,71 +133,53 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
room.buzzerState.roundOpen = true;
|
||||
broadcast(room, { type: "round_open", room: publicRoom(room) });
|
||||
break;
|
||||
|
||||
case "close_round":
|
||||
room.buzzerState.roundOpen = false;
|
||||
broadcast(room, { type: "round_closed", room: publicRoom(room) });
|
||||
break;
|
||||
|
||||
case "reset_buzzer":
|
||||
room.buzzerState = freshBuzzer();
|
||||
broadcast(room, { type: "buzzer_reset", room: publicRoom(room) });
|
||||
break;
|
||||
|
||||
case "update_settings": {
|
||||
const s = msg.settings ?? {};
|
||||
const st = room.settings;
|
||||
if (s.mode === "individual" || s.mode === "teams") st.mode = s.mode;
|
||||
if (typeof s.numTeams === "number") st.numTeams = Math.min(8, Math.max(2, s.numTeams | 0));
|
||||
if (Array.isArray(s.teamNames)) st.teamNames = s.teamNames.slice(0, 8).map((n: unknown) => sanitize(n, 32));
|
||||
if (typeof s.numTeams === "number") {
|
||||
const newN = Math.max(2, Math.min(64, s.numTeams | 0));
|
||||
// grow team names with greek names if needed
|
||||
while (st.teamNames.length < newN) st.teamNames.push(greekName(st.teamNames.length));
|
||||
st.numTeams = newN;
|
||||
}
|
||||
if (Array.isArray(s.teamNames)) st.teamNames = s.teamNames.slice(0, st.numTeams).map((n: unknown) => sanitize(n, 32));
|
||||
if (typeof s.playerPickTeam === "boolean") st.playerPickTeam = s.playerPickTeam;
|
||||
if (typeof s.showBuzzOrder === "boolean") st.showBuzzOrder = s.showBuzzOrder;
|
||||
if (typeof s.buzzerLockout === "boolean") st.buzzerLockout = s.buzzerLockout;
|
||||
if (typeof s.timerSeconds === "number") st.timerSeconds = Math.min(300, Math.max(0, s.timerSeconds | 0));
|
||||
if (typeof s.timerSeconds === "number") st.timerSeconds = Math.min(600, Math.max(0, s.timerSeconds | 0));
|
||||
broadcast(room, { type: "room_update", room: publicRoom(room) });
|
||||
tx(ws, { type: "settings_updated", room: publicRoom(room) });
|
||||
break;
|
||||
}
|
||||
|
||||
case "assign_team": {
|
||||
const p = room.players.get(sanitize(msg.playerId, 12));
|
||||
if (p) {
|
||||
p.teamIndex = typeof msg.teamIndex === "number"
|
||||
? Math.max(0, Math.min(7, msg.teamIndex | 0))
|
||||
: null;
|
||||
p.teamIndex = typeof msg.teamIndex === "number" ? Math.max(0, Math.min(room.settings.numTeams - 1, msg.teamIndex | 0)) : null;
|
||||
broadcast(room, { type: "room_update", room: publicRoom(room) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "kick_player": {
|
||||
const p = room.players.get(sanitize(msg.playerId, 12));
|
||||
if (p) {
|
||||
if (p.ws) {
|
||||
try { p.ws.send(JSON.stringify({ type: "kicked" })); } catch {}
|
||||
wsToPlayer.delete(p.ws);
|
||||
}
|
||||
if (p.ws) { try { p.ws.send(JSON.stringify({ type: "kicked" })); } catch {} wsToPlayer.delete(p.ws); }
|
||||
room.players.delete(p.id);
|
||||
broadcast(room, { type: "room_update", room: publicRoom(room) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "lock_room":
|
||||
room.locked = msg.locked === true;
|
||||
broadcast(room, { type: "room_update", room: publicRoom(room) });
|
||||
break;
|
||||
|
||||
case "lock_teams":
|
||||
room.teamLocked = msg.locked === true;
|
||||
broadcast(room, { type: "room_update", room: publicRoom(room) });
|
||||
break;
|
||||
|
||||
case "reset_teams":
|
||||
for (const p of room.players.values()) p.teamIndex = null;
|
||||
broadcast(room, { type: "room_update", room: publicRoom(room) });
|
||||
break;
|
||||
|
||||
case "lock_room": room.locked = msg.locked === true; broadcast(room, { type: "room_update", room: publicRoom(room) }); break;
|
||||
case "lock_teams": room.teamLocked = msg.locked === true; broadcast(room, { type: "room_update", room: publicRoom(room) }); break;
|
||||
case "reset_teams": for (const p of room.players.values()) p.teamIndex = null; broadcast(room, { type: "room_update", room: publicRoom(room) }); break;
|
||||
case "end_room":
|
||||
broadcast(room, { type: "room_ended" });
|
||||
for (const p of room.players.values()) if (p.ws) wsToPlayer.delete(p.ws);
|
||||
@@ -230,7 +190,6 @@ export function handleMessage(ws: WS, raw: string) {
|
||||
}
|
||||
|
||||
export function handleClose(ws: WS) {
|
||||
// mod disconnect
|
||||
for (const room of rooms.values()) {
|
||||
if (room.modWs === ws) {
|
||||
room.modWs = null;
|
||||
@@ -238,7 +197,6 @@ export function handleClose(ws: WS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// player disconnect
|
||||
const ctx = wsToPlayer.get(ws);
|
||||
if (ctx) {
|
||||
wsToPlayer.delete(ws);
|
||||
|
||||
Reference in New Issue
Block a user