Update buzzer app to serve static files and handle WebSocket connections

Integrates static file serving for HTML, CSS, and JS, and sets up a WebSocket server for real-time communication.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: f3ac8eb3-f610-4678-ab6e-ebf900098be4
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 182c9671-a459-4b94-a7eb-1c7a0cefe768
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
This commit is contained in:
2026-03-25 21:07:18 +00:00
parent ce13860cd1
commit 83bd893d93
6 changed files with 1680 additions and 745 deletions

99
server/buzzer-rooms.ts Normal file
View File

@@ -0,0 +1,99 @@
import type { WebSocket } from "ws";
export interface RoomSettings {
mode: "individual" | "teams";
numTeams: number;
teamNames: string[];
playerPickTeam: boolean;
showBuzzOrder: boolean;
buzzerLockout: boolean;
timerSeconds: number;
}
export interface Player {
id: string;
name: string;
teamIndex: number | null;
ws: WebSocket | null;
isConnected: boolean;
}
export interface BuzzerState {
roundOpen: boolean;
buzzOrder: string[];
buzzTimes: Map<string, number>;
}
export interface Room {
id: string;
moderatorSecret: string;
modWs: WebSocket | null;
settings: RoomSettings;
players: Map<string, Player>;
buzzerState: BuzzerState;
locked: boolean;
teamLocked: boolean;
}
export const rooms = new Map<string, Room>();
export const wsToPlayer = new Map<WebSocket, { roomId: string; playerId: string }>();
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 = "";
for (let i = 0; i < len; i++) s += c[Math.floor(Math.random() * c.length)];
return s;
}
export function sanitize(v: unknown, max = 32): string {
return String(v ?? "").trim().replace(/[<>"'`]/g, "").slice(0, max);
}
export function freshBuzzer(): BuzzerState {
return { roundOpen: false, buzzOrder: [], buzzTimes: new Map() };
}
export function publicRoom(room: Room) {
return {
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,
},
players: Array.from(room.players.values()).map(p => ({
id: p.id,
name: p.name,
teamIndex: p.teamIndex,
isConnected: p.isConnected,
})),
};
}
export function broadcast(room: Room, msg: object, exclude?: WebSocket) {
const d = JSON.stringify(msg);
for (const p of room.players.values())
if (p.ws && p.isConnected && p.ws !== exclude) try { p.ws.send(d); } catch {}
if (room.modWs && room.modWs !== exclude) try { room.modWs.send(d); } catch {}
}
export function toMod(room: Room, msg: object) {
if (room.modWs) try { room.modWs.send(JSON.stringify(msg)); } catch {}
}

214
server/buzzer-ws.ts Normal file
View File

@@ -0,0 +1,214 @@
import type { WebSocket } from "ws";
import {
rooms, wsToPlayer,
Room, Player,
genId, greekName, sanitize, freshBuzzer, publicRoom, broadcast, toMod,
} from "./buzzer-rooms";
const tx = (ws: WebSocket, msg: object) => { try { ws.send(JSON.stringify(msg)); } catch {} };
const er = (ws: WebSocket, m: string) => tx(ws, { type: "error", message: m });
const modOf = (ws: WebSocket): Room | null => { for (const r of rooms.values()) if (r.modWs === ws) return r; return null; };
export function handleMessage(ws: WebSocket, raw: string) {
let msg: any;
try { msg = JSON.parse(raw); } catch { return; }
const type: string = msg?.type ?? "";
if (type === "create_room") {
const s = msg.settings ?? {};
const numTeams = Math.max(2, Math.min(64, (s.numTeams ?? 2) | 0));
const teamNames: string[] = [];
for (let i = 0; i < numTeams; i++)
teamNames.push(s.teamNames?.[i] ? sanitize(s.teamNames[i], 32) : greekName(i));
const room: Room = {
id: genId(6),
moderatorSecret: genId(24),
modWs: ws,
settings: {
mode: s.mode === "teams" ? "teams" : "individual",
numTeams,
teamNames,
playerPickTeam: s.playerPickTeam === true,
showBuzzOrder: s.showBuzzOrder !== false,
buzzerLockout: s.buzzerLockout !== false,
timerSeconds: Math.min(600, Math.max(0, (s.timerSeconds ?? 0) | 0)),
},
players: new Map(),
buzzerState: freshBuzzer(),
locked: false,
teamLocked: false,
};
rooms.set(room.id, room);
tx(ws, { type: "room_created", roomId: room.id, modSecret: room.moderatorSecret, room: publicRoom(room) });
return;
}
if (type === "mod_rejoin") {
const room = rooms.get(sanitize(msg.roomId, 10));
if (!room || room.moderatorSecret !== sanitize(msg.modSecret, 32)) {
er(ws, "Invalid credentials"); return;
}
room.modWs = ws;
tx(ws, { type: "mod_joined", room: publicRoom(room) });
broadcast(room, { type: "room_update", room: publicRoom(room) }, ws);
return;
}
if (type === "join_room") {
if (msg.modSecret) { er(ws, "Unauthorized"); return; }
const room = rooms.get(sanitize(msg.roomId ?? "", 10));
if (!room) { er(ws, "Room not found"); return; }
if (room.locked) { er(ws, "Room is locked"); return; }
const name = sanitize(msg.playerName ?? "Player", 24) || "Player";
const existingId = sanitize(msg.playerId ?? "", 12);
let player: Player | undefined;
if (existingId && room.players.has(existingId)) {
player = room.players.get(existingId)!;
player.ws = ws; player.isConnected = true; player.name = name;
} else {
player = { id: genId(), name, teamIndex: null, ws, isConnected: true };
room.players.set(player.id, player);
}
wsToPlayer.set(ws, { roomId: room.id, playerId: player.id });
tx(ws, { type: "joined", playerId: player.id, room: publicRoom(room) });
broadcast(room, { type: "room_update", room: publicRoom(room) }, ws);
return;
}
if (type === "pick_team") {
const ctx = wsToPlayer.get(ws);
if (!ctx) return;
const room = rooms.get(ctx.roomId);
if (!room) return;
if (room.teamLocked || !room.settings.playerPickTeam || room.settings.mode !== "teams") {
er(ws, "Team selection not allowed"); return;
}
const p = room.players.get(ctx.playerId);
if (!p) return;
const ti = typeof msg.teamIndex === "number" ? msg.teamIndex : null;
p.teamIndex = ti !== null ? Math.max(0, Math.min(room.settings.numTeams - 1, ti | 0)) : null;
broadcast(room, { type: "room_update", room: publicRoom(room) });
return;
}
if (type === "buzz") {
const ctx = wsToPlayer.get(ws);
if (!ctx) return;
const room = rooms.get(ctx.roomId);
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; }
bz.buzzOrder.push(ctx.playerId);
bz.buzzTimes.set(ctx.playerId, Date.now());
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) });
return;
}
const room = modOf(ws);
if (!room) { er(ws, "Not authorized"); return; }
switch (type) {
case "open_round":
room.buzzerState = freshBuzzer();
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") {
const newN = Math.max(2, Math.min(64, s.numTeams | 0));
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(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(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);
}
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 "end_room":
broadcast(room, { type: "room_ended" });
for (const p of room.players.values()) if (p.ws) wsToPlayer.delete(p.ws);
room.modWs = null;
rooms.delete(room.id);
break;
}
}
export function handleClose(ws: WebSocket) {
for (const room of rooms.values()) {
if (room.modWs === ws) {
room.modWs = null;
broadcast(room, { type: "mod_disconnected" });
return;
}
}
const ctx = wsToPlayer.get(ws);
if (ctx) {
wsToPlayer.delete(ws);
const room = rooms.get(ctx.roomId);
if (room) {
const p = room.players.get(ctx.playerId);
if (p) { p.isConnected = false; p.ws = null; }
broadcast(room, { type: "room_update", room: publicRoom(room) });
}
}
}

View File

@@ -1,16 +1,46 @@
import type { Express } from "express"; import type { Express } from "express";
import { createServer, type Server } from "http"; import { createServer, type Server } from "http";
import { storage } from "./storage"; import { WebSocketServer } from "ws";
import fs from "fs";
import path from "path";
import { handleMessage, handleClose } from "./buzzer-ws";
export async function registerRoutes( export async function registerRoutes(
httpServer: Server, httpServer: Server,
app: Express app: Express
): Promise<Server> { ): Promise<Server> {
// put application routes here // Serve buzzer static files
// prefix all routes with /api const publicDir = path.resolve(process.cwd(), "src/public");
// use storage to perform CRUD operations on the storage interface app.get("/", (_req, res) => {
// e.g. storage.insertUser(user) or storage.getUserByUsername(username) res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(fs.readFileSync(path.join(publicDir, "index.html"), "utf-8"));
});
app.get("/styles.css", (_req, res) => {
res.setHeader("Content-Type", "text/css");
res.send(fs.readFileSync(path.join(publicDir, "styles.css"), "utf-8"));
});
app.get("/script.js", (_req, res) => {
res.setHeader("Content-Type", "text/javascript");
res.send(fs.readFileSync(path.join(publicDir, "script.js"), "utf-8"));
});
// WebSocket server for buzzer
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
wss.on("connection", (ws) => {
ws.on("message", (data) => {
if (typeof data === "string") {
handleMessage(ws, data);
} else {
handleMessage(ws, data.toString());
}
});
ws.on("close", () => handleClose(ws));
ws.on("error", () => { try { ws.close(); } catch {} });
});
return httpServer; return httpServer;
} }

View File

@@ -7,8 +7,7 @@
<title>Buzzer Platform</title> <title>Buzzer Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&display=swap" <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
@@ -17,9 +16,9 @@
<!-- HEADER --> <!-- HEADER -->
<header> <header>
<div> <div class="logo-wrap">
<div class="logo">BUZZER</div> <div class="logo">BUZZER</div>
<div class="logo-sub">// QUIZ CONTROL</div> <div class="logo-sub">// QUIZ CONTROL PLATFORM</div>
</div> </div>
<div class="hdr-r"> <div class="hdr-r">
<div class="room-chip" id="hdr-room"></div> <div class="room-chip" id="hdr-room"></div>
@@ -33,21 +32,28 @@
<!-- ══════════ LANDING ══════════ --> <!-- ══════════ LANDING ══════════ -->
<div class="scr" id="s-land"> <div class="scr" id="s-land">
<div class="hero"> <div class="hero">
<div class="hero-decoration"></div>
<h1 class="glow">BUZZ</h1> <h1 class="glow">BUZZ</h1>
<p>REAL-TIME QUIZ BUZZER SYSTEM</p> <p>REAL-TIME QUIZ BUZZER SYSTEM</p>
<div class="hero-badge">
<div class="hero-badge-dot"></div>
WEBSOCKET POWERED
</div>
</div> </div>
<div class="land-cards"> <div class="land-cards">
<div class="land-card"> <div class="land-card">
<div class="land-card-icon"></div>
<h2>// HOST A SESSION</h2> <h2>// HOST A SESSION</h2>
<p>Create a room, configure teams and rules, then control the buzzer live.</p> <p>Create a room, configure teams and rules, then control the buzzer live.</p>
<button class="btn btn-g" onclick="goSetup()">CREATE ROOM →</button> <button class="btn btn-g" onclick="goSetup()">CREATE ROOM →</button>
</div> </div>
<div class="land-card"> <div class="land-card">
<div class="land-card-icon"></div>
<h2>// JOIN A SESSION</h2> <h2>// JOIN A SESSION</h2>
<p>Enter a room code and your name to join an existing session.</p> <p>Enter a room code and your name to join an existing session.</p>
<div class="field"> <div class="field">
<label>ROOM CODE</label> <label>ROOM CODE</label>
<input id="ji-code" maxlength="8" placeholder="XXXXXX" style="letter-spacing:4px;text-transform:uppercase" <input id="ji-code" maxlength="8" placeholder="XXXXXX" style="letter-spacing:5px;text-transform:uppercase;font-size:20px;"
oninput="this.value=this.value.toUpperCase()" /> oninput="this.value=this.value.toUpperCase()" />
</div> </div>
<div class="field"> <div class="field">
@@ -65,7 +71,7 @@
<!-- ══════════ SETUP ══════════ --> <!-- ══════════ SETUP ══════════ -->
<div class="scr" id="s-setup"> <div class="scr" id="s-setup">
<div class="setup-wrap"> <div class="setup-wrap">
<div> <div class="setup-header">
<div class="setup-title">// NEW ROOM SETUP</div> <div class="setup-title">// NEW ROOM SETUP</div>
<div class="setup-sub">Configure before creating — all settings are adjustable in-game too.</div> <div class="setup-sub">Configure before creating — all settings are adjustable in-game too.</div>
</div> </div>
@@ -81,28 +87,26 @@
</div> </div>
</div> </div>
<div id="team-opts"> <div id="team-opts">
<div class="field" style="margin-top:16px;"> <div class="field" style="margin-top:20px;">
<label>NUMBER OF TEAMS</label> <label>NUMBER OF TEAMS</label>
<div style="display:flex;align-items:center;gap:12px;"> <div style="display:flex;align-items:center;gap:14px;">
<input type="number" id="st-numteams" value="2" min="2" max="64" style="width:90px;" <input type="number" id="st-numteams" value="2" min="2" max="64" style="width:100px;"
oninput="renderSetupTeamNames()" /> oninput="renderSetupTeamNames()" />
<span style="font-size:12px;color:var(--dim);">2 64</span> <span style="font-size:12px;color:var(--dim);letter-spacing:1px;">2 64</span>
</div> </div>
</div> </div>
<div class="field" style="margin-top:12px;"> <div class="field" style="margin-top:14px;">
<label>TEAM NAMES <span style="color:var(--dim);font-size:9px;">(AUTO-FILLED WITH GREEK <label>TEAM NAMES <span style="color:var(--dim);font-size:9px;letter-spacing:1px;">(AUTO-FILLED WITH GREEK ALPHABET)</span></label>
ALPHABET)</span></label>
<div id="setup-team-names" <div id="setup-team-names"
style="display:flex;flex-direction:column;gap:8px;max-height:240px;overflow-y:auto;padding-right:4px;"> style="display:flex;flex-direction:column;gap:9px;max-height:240px;overflow-y:auto;padding-right:4px;">
</div> </div>
</div> </div>
<div class="tog-row" style="margin-top:12px;"> <div class="tog-row" style="margin-top:14px;">
<div> <div>
<div class="lbl">PLAYERS PICK OWN TEAM</div> <div class="lbl">PLAYERS PICK OWN TEAM</div>
<div class="lbl-sub">If off, moderator assigns teams manually</div> <div class="lbl-sub">If off, moderator assigns teams manually</div>
</div> </div>
<label class="tog"><input type="checkbox" id="st-playerpick" checked /><span <label class="tog"><input type="checkbox" id="st-playerpick" checked /><span class="tog-track"></span></label>
class="tog-track"></span></label>
</div> </div>
</div> </div>
</div> </div>
@@ -121,8 +125,7 @@
<div class="lbl">SHOW FULL BUZZ ORDER TO PLAYERS</div> <div class="lbl">SHOW FULL BUZZ ORDER TO PLAYERS</div>
<div class="lbl-sub">If off, players only see if they were first</div> <div class="lbl-sub">If off, players only see if they were first</div>
</div> </div>
<label class="tog"><input type="checkbox" id="st-showorder" checked /><span <label class="tog"><input type="checkbox" id="st-showorder" checked /><span class="tog-track"></span></label>
class="tog-track"></span></label>
</div> </div>
</div> </div>
@@ -133,18 +136,17 @@
<div class="lbl">ENABLE COUNTDOWN TIMER</div> <div class="lbl">ENABLE COUNTDOWN TIMER</div>
<div class="lbl-sub">Auto-closes round when it hits zero</div> <div class="lbl-sub">Auto-closes round when it hits zero</div>
</div> </div>
<label class="tog"><input type="checkbox" id="st-usetimer" onchange="toggleTimerField()" /><span <label class="tog"><input type="checkbox" id="st-usetimer" onchange="toggleTimerField()" /><span class="tog-track"></span></label>
class="tog-track"></span></label>
</div> </div>
<div id="timer-field" style="display:none;margin-top:14px;"> <div id="timer-field" style="display:none;margin-top:16px;">
<div class="field"> <div class="field">
<label>SECONDS PER ROUND</label> <label>SECONDS PER ROUND</label>
<input type="number" id="st-timersec" value="30" min="5" max="600" style="max-width:160px;" /> <input type="number" id="st-timersec" value="30" min="5" max="600" style="max-width:180px;" />
</div> </div>
</div> </div>
</div> </div>
<div style="display:flex;gap:10px;justify-content:flex-end;padding-bottom:32px;"> <div style="display:flex;gap:12px;justify-content:flex-end;padding-bottom:40px;">
<button class="btn btn-ghost" onclick="showScr('s-land')">← BACK</button> <button class="btn btn-ghost" onclick="showScr('s-land')">← BACK</button>
<button class="btn btn-g" onclick="createRoom()">CREATE ROOM →</button> <button class="btn btn-g" onclick="createRoom()">CREATE ROOM →</button>
</div> </div>
@@ -155,11 +157,11 @@
<div class="scr" id="s-mod"> <div class="scr" id="s-mod">
<div class="mod-side"> <div class="mod-side">
<div class="side-sec"> <div class="side-sec">
<div class="side-label">ROOM</div> <div class="side-label">ROOM CODE</div>
<div class="side-room-code glow" id="mod-code">──────</div> <div class="side-room-code glow" id="mod-code">──────</div>
<div class="side-hint">SHARE WITH PLAYERS</div> <div class="side-hint" style="margin-top:6px;">SHARE WITH PLAYERS</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;"> <div style="display:flex;gap:7px;flex-wrap:wrap;margin-top:14px;">
<button class="btn btn-ghost btn-sm" onclick="copyCode()">COPY</button> <button class="btn btn-ghost btn-sm" onclick="copyCode()">COPY CODE</button>
<button class="btn btn-red btn-sm" onclick="openModal('m-end')">END ROOM</button> <button class="btn btn-red btn-sm" onclick="openModal('m-end')">END ROOM</button>
</div> </div>
</div> </div>
@@ -171,7 +173,7 @@
<div class="timer-digits" id="mod-timer-disp">0:30</div> <div class="timer-digits" id="mod-timer-disp">0:30</div>
<div class="timer-controls"> <div class="timer-controls">
<button class="btn btn-g btn-sm" id="btn-timer-ss" onclick="modTimerToggle()">START</button> <button class="btn btn-g btn-sm" id="btn-timer-ss" onclick="modTimerToggle()">START</button>
<button class="btn btn-ghost btn-sm" onclick="modTimerReset()"></button> <button class="btn btn-ghost btn-sm" onclick="modTimerReset()"> RESET</button>
</div> </div>
<div class="timer-set-row"> <div class="timer-set-row">
<span class="side-hint">SET</span> <span class="side-hint">SET</span>
@@ -184,7 +186,7 @@
<!-- BUZZER CONTROLS --> <!-- BUZZER CONTROLS -->
<div class="side-sec"> <div class="side-sec">
<div class="side-label">BUZZER</div> <div class="side-label">BUZZER CONTROLS</div>
<div class="side-btn-group"> <div class="side-btn-group">
<button class="btn btn-g btn-full" onclick="ws_send({type:'open_round'})">▶ OPEN ROUND</button> <button class="btn btn-g btn-full" onclick="ws_send({type:'open_round'})">▶ OPEN ROUND</button>
<button class="btn btn-ghost btn-full" onclick="ws_send({type:'close_round'})">■ CLOSE ROUND</button> <button class="btn btn-ghost btn-full" onclick="ws_send({type:'close_round'})">■ CLOSE ROUND</button>
@@ -194,7 +196,7 @@
<!-- ROOM CONTROLS --> <!-- ROOM CONTROLS -->
<div class="side-sec"> <div class="side-sec">
<div class="side-label">CONTROLS</div> <div class="side-label">ROOM CONTROLS</div>
<div class="tog-row"> <div class="tog-row">
<div class="lbl">LOCK ROOM</div> <div class="lbl">LOCK ROOM</div>
<label class="tog"><input type="checkbox" id="lock-room-tog" <label class="tog"><input type="checkbox" id="lock-room-tog"
@@ -205,9 +207,8 @@
<label class="tog"><input type="checkbox" id="lock-teams-tog" <label class="tog"><input type="checkbox" id="lock-teams-tog"
onchange="ws_send({type:'lock_teams',locked:this.checked})" /><span class="tog-track"></span></label> onchange="ws_send({type:'lock_teams',locked:this.checked})" /><span class="tog-track"></span></label>
</div> </div>
<div style="margin-top:10px;display:flex;flex-direction:column;gap:5px;"> <div style="margin-top:12px;display:flex;flex-direction:column;gap:6px;">
<button class="btn btn-ghost btn-sm btn-full" onclick="ws_send({type:'reset_teams'})">CLEAR ALL <button class="btn btn-ghost btn-sm btn-full" onclick="ws_send({type:'reset_teams'})">CLEAR ALL TEAMS</button>
TEAMS</button>
</div> </div>
</div> </div>
@@ -238,7 +239,7 @@
<div id="tab-players" style="display:none"> <div id="tab-players" style="display:none">
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
<span>CONNECTED PLAYERS</span> CONNECTED PLAYERS
<span class="tag tag-g" id="pcount-badge">0</span> <span class="tag tag-g" id="pcount-badge">0</span>
</div> </div>
<div class="player-list" id="mod-plist"> <div class="player-list" id="mod-plist">
@@ -297,16 +298,16 @@
<div class="sl">NUMBER OF TEAMS</div> <div class="sl">NUMBER OF TEAMS</div>
</div> </div>
<div style="display:flex;align-items:center;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;">
<input type="number" id="ls-numteams" min="2" max="64" style="width:80px;" <input type="number" id="ls-numteams" min="2" max="64" style="width:84px;"
onchange="pushSetting('numTeams',+this.value)" /> onchange="pushSetting('numTeams',+this.value)" />
<button class="btn btn-ghost btn-sm" <button class="btn btn-ghost btn-sm"
onclick="pushSetting('numTeams',+document.getElementById('ls-numteams').value)">APPLY</button> onclick="pushSetting('numTeams',+document.getElementById('ls-numteams').value)">APPLY</button>
</div> </div>
</div> </div>
<div style="margin-top:18px;"> <div style="margin-top:20px;">
<div class="lbl" style="margin-bottom:10px;">TEAM NAMES</div> <div class="lbl" style="margin-bottom:12px;">TEAM NAMES</div>
<div id="ls-team-names" <div id="ls-team-names"
style="display:flex;flex-direction:column;gap:8px;max-height:260px;overflow-y:auto;padding-right:4px;"> style="display:flex;flex-direction:column;gap:9px;max-height:280px;overflow-y:auto;padding-right:4px;">
</div> </div>
</div> </div>
</div> </div>
@@ -330,6 +331,11 @@
</div> </div>
<div class="buzz-wrap"> <div class="buzz-wrap">
<div class="buzz-ripple" id="buzz-ripple" style="display:none;">
<div class="buzz-ring"></div>
<div class="buzz-ring"></div>
<div class="buzz-ring"></div>
</div>
<button id="buzz-btn" class="s-closed" disabled onclick="doBuzz()">BUZZ</button> <button id="buzz-btn" class="s-closed" disabled onclick="doBuzz()">BUZZ</button>
<div class="buzz-status" id="buzz-status">WAITING FOR ROUND</div> <div class="buzz-status" id="buzz-status">WAITING FOR ROUND</div>
</div> </div>
@@ -351,8 +357,8 @@
<div class="modal-bg" id="m-rejoin"> <div class="modal-bg" id="m-rejoin">
<div class="modal"> <div class="modal">
<h2>// REJOIN SESSION</h2> <h2>// REJOIN SESSION</h2>
<p style="font-size:13px;color:var(--dim);">You have a saved mod session. Rejoin it?</p> <p style="font-size:13px;color:var(--dim);line-height:1.7;">You have a saved mod session. Rejoin it?</p>
<div style="font-size:24px;letter-spacing:6px;color:var(--g);font-weight:700;" id="m-rejoin-code"></div> <div style="font-size:28px;letter-spacing:8px;color:var(--g);font-weight:700;text-shadow:0 0 12px var(--g);" id="m-rejoin-code"></div>
<div class="modal-btns"> <div class="modal-btns">
<button class="btn btn-ghost btn-sm" onclick="clearMod();closeModal('m-rejoin')">DISCARD</button> <button class="btn btn-ghost btn-sm" onclick="clearMod();closeModal('m-rejoin')">DISCARD</button>
<button class="btn btn-g btn-sm" onclick="doRejoin()">REJOIN →</button> <button class="btn btn-g btn-sm" onclick="doRejoin()">REJOIN →</button>
@@ -363,7 +369,7 @@
<div class="modal-bg" id="m-end"> <div class="modal-bg" id="m-end">
<div class="modal"> <div class="modal">
<h2>// END ROOM</h2> <h2>// END ROOM</h2>
<p style="font-size:13px;color:var(--dim);">All players will be disconnected and the room deleted.</p> <p style="font-size:13px;color:var(--dim);line-height:1.7;">All players will be disconnected and the room deleted.</p>
<div class="modal-btns"> <div class="modal-btns">
<button class="btn btn-ghost btn-sm" onclick="closeModal('m-end')">CANCEL</button> <button class="btn btn-ghost btn-sm" onclick="closeModal('m-end')">CANCEL</button>
<button class="btn btn-red btn-sm" onclick="ws_send({type:'end_room'});closeModal('m-end')">END ROOM</button> <button class="btn btn-red btn-sm" onclick="ws_send({type:'end_room'});closeModal('m-end')">END ROOM</button>
@@ -372,6 +378,9 @@
</div> </div>
<div id="toasts"></div> <div id="toasts"></div>
<!-- GSAP -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="/script.js"></script> <script src="/script.js"></script>
</body> </body>

View File

@@ -2,25 +2,13 @@
// GREEK ALPHABET (mirrors server for display) // 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"]; 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) { 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;}
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]; function greekName(i){const cycle=Math.floor(i/GREEK.length);return cycle===0?GREEK[i%GREEK.length]:`${GREEK[i%GREEK.length]} ${toRoman(cycle+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 // COLORS — HSL wheel, infinite unique colors
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
function teamColor(i) { function teamColor(i){const hue=(i*137.508)%360;return `hsl(${hue},90%,58%)`;}
const hue = (i * 137.508) % 360; // golden angle spacing
return `hsl(${hue},90%,58%)`;
}
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// STATE // STATE
@@ -56,11 +44,9 @@ function schedReconn() {
clearTimeout(reconnTimer); clearTimeout(reconnTimer);
const d=Math.min(8000,500*Math.pow(1.5,reconnAttempts++)); const d=Math.min(8000,500*Math.pow(1.5,reconnAttempts++));
reconnTimer=setTimeout(()=>{ reconnTimer=setTimeout(()=>{
if (role === 'mod') { if(role==='mod'){const m=loadMod();if(m)connect(()=>ws_send({type:'mod_rejoin',roomId:m.id,modSecret:m.s}));}
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 if (role === 'player' && myId) { else connect(null);
const p = loadPlay(); if (p) connect(() => ws_send({ type: 'join_room', roomId: p.rid, playerName: p.name, playerId: p.pid }));
} else connect(null);
},d); },d);
} }
@@ -130,19 +116,53 @@ function handle(msg) {
} }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// SCREENS // SCREENS — with GSAP transitions
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
let _currentScr = null;
function showScr(id){ function showScr(id){
document.querySelectorAll('.scr').forEach(s => { s.classList.remove('on'); s.style.display = 'none'; }); const prev = _currentScr;
const el = document.getElementById(id); const next = document.getElementById(id);
el.style.display = 'flex'; el.classList.add('on');
// 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'); const chip=document.getElementById('hdr-room');
if(room?.id){chip.textContent='['+room.id+']';chip.style.display='block';} if(room?.id){chip.textContent='['+room.id+']';chip.style.display='block';}
else chip.style.display='none'; else chip.style.display='none';
} }
function setConn(on){ function setConn(on){
document.getElementById('cdot').className='conn-dot'+(on?' on':''); document.getElementById('cdot').className='conn-dot'+(on?' on':'');
document.getElementById('clbl').textContent=on?'ONLINE':'OFFLINE'; document.getElementById('clbl').textContent=on?'ONLINE':'OFFLINE';
if(on && typeof gsap!=='undefined'){
gsap.fromTo('#clbl',{opacity:0.3},{opacity:1,duration:0.4});
}
} }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
@@ -175,8 +195,23 @@ function segSelect(groupId, el) {
el.classList.add('active'); el.classList.add('active');
if(groupId==='seg-mode'){ if(groupId==='seg-mode'){
const isTeams=el.dataset.v==='teams'; const isTeams=el.dataset.v==='teams';
document.getElementById('team-opts').style.display = isTeams ? 'flex' : 'none'; const teamOpts=document.getElementById('team-opts');
document.getElementById('team-opts').style.flexDirection = 'column'; 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';
}
}
} }
} }
@@ -187,10 +222,10 @@ function renderSetupTeamNames() {
container.innerHTML=''; container.innerHTML='';
for(let i=0;i<n;i++){ for(let i=0;i<n;i++){
const row=document.createElement('div'); const row=document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;'; row.style.cssText='display:flex;align-items:center;gap:10px;';
const dot=document.createElement('div'); const dot=document.createElement('div');
const c=teamColor(i); 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;`; 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'); const inp=document.createElement('input');
inp.type='text';inp.maxLength=32; inp.type='text';inp.maxLength=32;
inp.value=existing[i]||greekName(i); inp.value=existing[i]||greekName(i);
@@ -201,7 +236,20 @@ function renderSetupTeamNames() {
} }
function toggleTimerField(){ function toggleTimerField(){
document.getElementById('timer-field').style.display = document.getElementById('st-usetimer').checked ? 'block' : 'none'; 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(){ function createRoom(){
@@ -240,7 +288,6 @@ function modTimerReset() {
modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30); modTimerRemaining=Math.max(5,parseInt(document.getElementById('mod-timer-set').value)||30);
document.getElementById('btn-timer-ss').textContent='START'; document.getElementById('btn-timer-ss').textContent='START';
renderModTimerDisplay(); renderModTimerDisplay();
// broadcast reset to players
broadcastTimerToPlayers(modTimerRemaining,false); broadcastTimerToPlayers(modTimerRemaining,false);
} }
function modTimerToggle(){ function modTimerToggle(){
@@ -262,10 +309,14 @@ function modTimerToggle() {
document.getElementById('btn-timer-ss').textContent='START'; document.getElementById('btn-timer-ss').textContent='START';
ws_send({type:'close_round'}); ws_send({type:'close_round'});
toast('TIME UP — round closed','warn'); 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); },1000);
} }
} }
function renderModTimerDisplay(){ function renderModTimerDisplay(){
const el=document.getElementById('mod-timer-disp'); const el=document.getElementById('mod-timer-disp');
const s=modTimerRemaining; const s=modTimerRemaining;
@@ -273,10 +324,7 @@ function renderModTimerDisplay() {
el.className='timer-digits'+(s<=5?' danger':s<=10?' warn':''); 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){ function broadcastTimerToPlayers(sec,running){
// encode into a BroadcastChannel so other tabs (players on same device) see it
try{ try{
const bc=new BroadcastChannel('buzzer_timer'); const bc=new BroadcastChannel('buzzer_timer');
bc.postMessage({sec,running}); bc.postMessage({sec,running});
@@ -332,6 +380,13 @@ function renderModBuzz(evt) {
</div> </div>
`; `;
listEl.appendChild(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}
);
}
}); });
} }
@@ -350,13 +405,13 @@ function renderModPlayerList() {
const tn=room.settings.teamNames[i]??greekName(i); const tn=room.settings.teamNames[i]??greekName(i);
opts+=`<option value="${i}" ${p.teamIndex===i?'selected':''}>${esc(tn)}</option>`; 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>`; 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'); const row=document.createElement('div');
row.className='pl-row'+(p.isConnected?'':' offline'); row.className='pl-row'+(p.isConnected?'':' offline');
row.innerHTML=` row.innerHTML=`
<div class="pl-info" style="flex:1;min-width:0;"> <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> <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>'} ${teamName?`<div class="pl-meta" style="color:${color}">${esc(teamName)}</div>`:'<div class="pl-meta">No team</div>'}
${teamSel} ${teamSel}
</div> </div>
@@ -385,6 +440,9 @@ function renderModTeams() {
<div class="tc-m">${members.map(p=>esc(p.name)).join('<br>')||'—'}</div> <div class="tc-m">${members.map(p=>esc(p.name)).join('<br>')||'—'}</div>
`; `;
grid.appendChild(card); 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});
}
} }
} }
@@ -409,10 +467,10 @@ function renderLiveTeamNames() {
container.innerHTML=''; container.innerHTML='';
for(let i=0;i<room.settings.numTeams;i++){ for(let i=0;i<room.settings.numTeams;i++){
const row=document.createElement('div'); const row=document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;'; row.style.cssText='display:flex;align-items:center;gap:10px;';
const dot=document.createElement('div'); const dot=document.createElement('div');
const c=teamColor(i); const c=teamColor(i);
dot.style.cssText = `width:11px;height:11px;border-radius:50%;background:${c};flex-shrink:0;`; dot.style.cssText=`width:12px;height:12px;border-radius:50%;background:${c};flex-shrink:0;`;
const inp=document.createElement('input'); const inp=document.createElement('input');
inp.type='text';inp.maxLength=32; inp.type='text';inp.maxLength=32;
inp.value=room.settings.teamNames[i]??greekName(i); inp.value=room.settings.teamNames[i]??greekName(i);
@@ -453,9 +511,7 @@ function initPlayerTimer() {
}catch{} }catch{}
} }
function stopPlayerTimer(){clearInterval(playerTimerInterval);} function stopPlayerTimer(){clearInterval(playerTimerInterval);}
function startPlayerTimer() { function startPlayerTimer(){}
// timer already synced via BroadcastChannel
}
function renderPlayerTimer(){ function renderPlayerTimer(){
const el=document.getElementById('p-timer'); const el=document.getElementById('p-timer');
const s=playerTimerRemaining; const s=playerTimerRemaining;
@@ -492,39 +548,69 @@ function renderTeamPicker() {
const isMine=me?.teamIndex===i; const isMine=me?.teamIndex===i;
const btn=document.createElement('button'); const btn=document.createElement('button');
btn.className='team-btn'+(isMine?' mine':''); btn.className='team-btn'+(isMine?' mine':'');
btn.style.borderColor = isMine ? color : 'var(--border)'; btn.style.borderColor=isMine?color:'var(--border2)';
btn.style.color=isMine?color:'var(--text)'; 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.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}); btn.onclick=()=>ws_send({type:'pick_team',teamIndex:i});
grid.appendChild(btn); 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(){ function renderPlayerBuzzer(){
if(!room)return; if(!room)return;
const btn=document.getElementById('buzz-btn'); const btn=document.getElementById('buzz-btn');
const sts=document.getElementById('buzz-status'); const sts=document.getElementById('buzz-status');
const ripple=document.getElementById('buzz-ripple');
const bz=room.buzzerState; const bz=room.buzzerState;
const already=bz.buzzOrder.includes(myId); const already=bz.buzzOrder.includes(myId);
const isFirst=bz.buzzOrder[0]===myId; const isFirst=bz.buzzOrder[0]===myId;
let newState='';
if(!bz.roundOpen){ if(!bz.roundOpen){
newState='closed';
btn.className='s-closed';btn.disabled=true;sts.textContent='WAITING FOR ROUND'; btn.className='s-closed';btn.disabled=true;sts.textContent='WAITING FOR ROUND';
ripple.style.display='none';
} else if(isFirst){ } else if(isFirst){
btn.className = 's-first'; btn.disabled = false; sts.textContent = '⚡ YOU BUZZED FIRST!'; newState='first';
btn.className='s-first';btn.disabled=false;sts.textContent='YOU BUZZED FIRST!';
ripple.style.display='flex';
} else if(already){ } else if(already){
const pos=bz.buzzOrder.indexOf(myId)+1; const pos=bz.buzzOrder.indexOf(myId)+1;
newState='buzzed';
btn.className='s-buzzed';btn.disabled=true;sts.textContent='BUZZED — #'+pos+' IN ORDER'; btn.className='s-buzzed';btn.disabled=true;sts.textContent='BUZZED — #'+pos+' IN ORDER';
ripple.style.display='none';
} else if(room.settings.buzzerLockout&&bz.buzzOrder.length>0){ } else if(room.settings.buzzerLockout&&bz.buzzOrder.length>0){
newState='locked';
btn.className='s-locked';btn.disabled=true;sts.textContent='BUZZER LOCKED OUT'; btn.className='s-locked';btn.disabled=true;sts.textContent='BUZZER LOCKED OUT';
ripple.style.display='none';
} else { } else {
newState='open';
btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!'; btn.className='s-open';btn.disabled=false;sts.textContent='ROUND OPEN — BUZZ!';
ripple.style.display='flex';
} }
// 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(){ function renderRoster(){
if(!room)return; if(!room)return;
const el=document.getElementById('p-roster'); 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; } 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=''; el.innerHTML='';
room.players.forEach(p=>{ room.players.forEach(p=>{
const isMe=p.id===myId; const isMe=p.id===myId;
@@ -533,9 +619,9 @@ function renderRoster() {
const row=document.createElement('div'); const row=document.createElement('div');
row.className='roster-row'+(isMe?' roster-me':''); row.className='roster-row'+(isMe?' roster-me':'');
row.innerHTML=` row.innerHTML=`
<div class="roster-dot" style="background:${p.isConnected ? (isMe ? 'var(--g)' : color) : 'var(--border)'}"></div> <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)">(YOU)</span>' : ''}</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}">${esc(teamName)}</div>` : ''} ${teamName?`<div style="font-size:12px;color:${color};letter-spacing:0.5px;">${esc(teamName)}</div>`:''}
`; `;
el.appendChild(row); el.appendChild(row);
}); });
@@ -548,13 +634,28 @@ function addFeed(evt) {
const teamStr=(room?.settings.mode==='teams'&&evt.teamIndex!==null)?` [${esc(room.settings.teamNames[evt.teamIndex]??'')}]`:''; const teamStr=(room?.settings.mode==='teams'&&evt.teamIndex!==null)?` [${esc(room.settings.teamNames[evt.teamIndex]??'')}]`:'';
const div=document.createElement('div'); const div=document.createElement('div');
div.className='feed-entry'+(isFirst?' first':''); div.className='feed-entry'+(isFirst?' first':'');
div.style.borderColor = isFirst ? 'var(--yellow)' : color; div.style.borderLeftColor=isFirst?'var(--yellow)':color;
div.innerHTML = `<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst ? ' <span style="color:var(--yellow)">— FIRST!</span>' : ''}`; div.innerHTML=`<strong>${esc(evt.playerName)}</strong>${teamStr} buzzed${isFirst?' <span style="color:var(--yellow);font-weight:700;"> — FIRST!</span>':''}`;
feed.prepend(div); 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); while(feed.children.length>30)feed.removeChild(feed.lastChild);
} }
function doBuzz() { ws_send({ type: 'buzz' }); } 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 // TABS
@@ -569,13 +670,35 @@ function setTab(name, el) {
if(name==='players')renderModPlayerList(); if(name==='players')renderModPlayerList();
if(name==='teams')renderModTeams(); if(name==='teams')renderModTeams();
if(name==='settings')renderModSettings(); 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 // MODALS / TOAST / UTIL
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
function openModal(id) { document.getElementById(id).classList.add('on'); } function openModal(id){
function closeModal(id) { document.getElementById(id).classList.remove('on'); } 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(){ function copyCode(){
if(!room)return; if(!room)return;
navigator.clipboard.writeText(room.id).then(()=>toast('Code copied','ok')); navigator.clipboard.writeText(room.id).then(()=>toast('Code copied','ok'));
@@ -584,8 +707,18 @@ function toast(msg, type = '') {
const el=document.createElement('div'); const el=document.createElement('div');
el.className='toast '+type;el.textContent=msg; el.className='toast '+type;el.textContent=msg;
document.getElementById('toasts').appendChild(el); 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); setTimeout(()=>{ el.style.transition='opacity .3s';el.style.opacity='0';setTimeout(()=>el.remove(),300);},2700);
} }
}
function esc(s){ function esc(s){
return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
} }
@@ -600,15 +733,36 @@ document.addEventListener('keydown', e => {
if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return; if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT')return;
e.preventDefault(); e.preventDefault();
const btn=document.getElementById('buzz-btn'); const btn=document.getElementById('buzz-btn');
if (!btn.disabled) { btn.click(); btn.style.transform = 'scale(.93)'; setTimeout(() => btn.style.transform = '', 120); } if(!btn.disabled){btn.click();}
} }
}); });
// close modals on backdrop click // close modals on backdrop click
document.querySelectorAll('.modal-bg').forEach(bg=>{ document.querySelectorAll('.modal-bg').forEach(bg=>{
bg.addEventListener('click', e => { if (e.target === bg) bg.classList.remove('on'); }); 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 // INIT
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
@@ -622,4 +776,6 @@ window.addEventListener('DOMContentLoaded', () => {
// init seg mode display // init seg mode display
const modeBtn=document.querySelector('#seg-mode .seg-opt.active'); const modeBtn=document.querySelector('#seg-mode .seg-opt.active');
if(modeBtn)segSelect('seg-mode',modeBtn); if(modeBtn)segSelect('seg-mode',modeBtn);
// animate landing after GSAP loads
setTimeout(animateLanding,80);
}); });

File diff suppressed because it is too large Load Diff