(() => { var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); // src/shared/protocol.ts var DEFAULT_SETTINGS = { rounds: 3, drawTime: 80, maxPlayers: 8, isPublic: true, customWords: "", customOnly: false }; function randomAvatar() { return { color: Math.floor(Math.random() * 12), eyes: Math.floor(Math.random() * 6), mouth: Math.floor(Math.random() * 6), extra: Math.floor(Math.random() * 6) }; } // src/client/net.ts var Net = class { constructor() { __publicField(this, "ws", null); __publicField(this, "pingTimer", null); __publicField(this, "onMessage", () => { }); __publicField(this, "onClose", () => { }); __publicField(this, "onOpen", () => { }); } connect() { const proto = location.protocol === "https:" ? "wss" : "ws"; this.ws = new WebSocket(`${proto}://${location.host}`); this.ws.onopen = () => { this.onOpen(); this.pingTimer = window.setInterval(() => this.send({ t: "ping" }), 5e3); }; this.ws.onmessage = (ev) => { try { const msg = JSON.parse(String(ev.data)); this.onMessage(msg); } catch { } }; this.ws.onclose = () => { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } this.onClose(); }; } send(msg) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(msg)); } } get connected() { return !!this.ws && this.ws.readyState === WebSocket.OPEN; } }; // src/client/canvas.ts var LW = 1e3; var LH = 750; var BATCH_MS = 40; var DrawEngine = class { constructor(canvas) { __publicField(this, "canvas"); __publicField(this, "ctx"); __publicField(this, "ops", []); __publicField(this, "activeLocal", null); __publicField(this, "activeRemote", /* @__PURE__ */ new Map()); __publicField(this, "pending", []); __publicField(this, "batchTimer", null); __publicField(this, "drawing", false); __publicField(this, "lastX", 0); __publicField(this, "lastY", 0); __publicField(this, "cb", { onBegin: () => { }, onPoints: () => { }, onEnd: () => { } }); __publicField(this, "canDraw", false); __publicField(this, "tool", "brush"); __publicField(this, "color", "#000000"); __publicField(this, "size", 12); __publicField(this, "onLocalDraw", () => { }); this.canvas = canvas; canvas.width = LW; canvas.height = LH; const ctx2 = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx2) throw new Error("no 2d context"); this.ctx = ctx2; this.clearSurface(); this.bindInput(); } // ---------------- surface ---------------- clearSurface() { this.ctx.save(); this.ctx.globalCompositeOperation = "source-over"; this.ctx.clearRect(0, 0, LW, LH); this.ctx.fillStyle = "#ffffff"; this.ctx.fillRect(0, 0, LW, LH); this.ctx.restore(); } reset(ops) { this.ops = ops.map((o) => ({ ...o, points: o.points.slice() })); this.activeRemote.clear(); this.activeLocal = null; this.redraw(); } redraw() { this.clearSurface(); for (const op of this.ops) this.applyOp(op); } // ---------------- stroke rendering ---------------- strokePath(points, color, size, erase) { const g = this.ctx; const pts = []; for (let i = 0; i + 1 < points.length; i += 2) pts.push(points[i] * LW, points[i + 1] * LH); const n = pts.length / 2; if (n === 0) return; g.save(); g.globalCompositeOperation = erase ? "destination-out" : "source-over"; g.strokeStyle = color; g.fillStyle = color; g.lineWidth = size; g.lineCap = "round"; g.lineJoin = "round"; if (n === 1) { g.beginPath(); g.arc(pts[0], pts[1], size / 2, 0, Math.PI * 2); g.fill(); } else { g.beginPath(); g.moveTo(pts[0], pts[1]); if (n === 2) { g.lineTo(pts[2], pts[3]); } else { for (let i = 1; i < n - 1; i++) { const mx = (pts[i * 2] + pts[(i + 1) * 2]) / 2; const my = (pts[i * 2 + 1] + pts[(i + 1) * 2 + 1]) / 2; g.quadraticCurveTo(pts[i * 2], pts[i * 2 + 1], mx, my); } g.lineTo(pts[(n - 1) * 2], pts[(n - 1) * 2 + 1]); } g.stroke(); } g.restore(); } applyOp(op) { switch (op.tool) { case "brush": this.strokePath(op.points, op.color, op.size, false); break; case "eraser": this.strokePath(op.points, "#000", op.size * 1.6, true); break; case "fill": if (op.points.length >= 2) this.floodFill(op.points[0] * LW, op.points[1] * LH, op.color); break; case "clear": this.clearSurface(); break; } } // ---------------- flood fill ---------------- floodFill(fx, fy, hex) { const x0 = Math.floor(fx), y0 = Math.floor(fy); if (x0 < 0 || y0 < 0 || x0 >= LW || y0 >= LH) return; const img = this.ctx.getImageData(0, 0, LW, LH); const data = img.data; const idx0 = (y0 * LW + x0) * 4; const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3]; const fr = parseInt(hex.slice(1, 3), 16), fg = parseInt(hex.slice(3, 5), 16), fb = parseInt(hex.slice(5, 7), 16); if (tr === fr && tg === fg && tb === fb && ta === 255) return; const TOL = 48; const match = (i) => Math.abs(data[i] - tr) <= TOL && Math.abs(data[i + 1] - tg) <= TOL && Math.abs(data[i + 2] - tb) <= TOL && Math.abs(data[i + 3] - ta) <= TOL; const stack = [x0, y0]; const seen = new Uint8Array(LW * LH); while (stack.length) { const y = stack.pop(), x = stack.pop(); const p = y * LW + x; if (seen[p]) continue; seen[p] = 1; const i = p * 4; if (!match(i)) continue; data[i] = fr; data[i + 1] = fg; data[i + 2] = fb; data[i + 3] = 255; if (x > 0) stack.push(x - 1, y); if (x < LW - 1) stack.push(x + 1, y); if (y > 0) stack.push(x, y - 1); if (y < LH - 1) stack.push(x, y + 1); } this.ctx.putImageData(img, 0, 0); } // ---------------- remote ops ---------------- remoteBegin(op) { const copy = { ...op, points: op.points.slice() }; if (op.tool === "brush" || op.tool === "eraser") { this.activeRemote.set(op.id, copy); this.ops.push(copy); if (copy.points.length >= 2) this.strokePath(copy.points, copy.color, copy.tool === "eraser" ? "#000" : copy.color, copy.size, copy.tool === "eraser"); } else { if (op.tool === "clear") { this.ops = [copy]; this.redraw(); } else { this.ops.push(copy); this.applyOp(copy); } } } remotePoints(id, points) { const op = this.activeRemote.get(id); if (!op) return; op.points.push(...points); this.strokePath(op.points, op.tool === "eraser" ? "#000" : op.color, op.tool === "eraser" ? op.size * 1.6 : op.size, op.tool === "eraser"); } remoteEnd(id) { this.activeRemote.delete(id); } undo(opId) { if (this.ops.some((o) => o.id === opId)) { this.ops = this.ops.filter((o) => o.id !== opId); } else { for (let i = this.ops.length - 1; i >= 0; i--) { if (this.ops[i].id === -1) { this.ops.splice(i, 1); break; } } } this.activeRemote.delete(opId); this.redraw(); } // ---------------- input ---------------- toNorm(e) { const r = this.canvas.getBoundingClientRect(); const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)); return [x, y]; } bindInput() { this.canvas.style.touchAction = "none"; this.canvas.addEventListener("pointerdown", (e) => { if (!this.canDraw || e.button === 2) return; e.preventDefault(); this.canvas.setPointerCapture(e.pointerId); const [x, y] = this.toNorm(e); this.onLocalDraw(); if (this.tool === "fill" || this.tool === "clear") { const op = { id: -1, playerId: "", tool: this.tool, color: this.color, size: this.size, points: [x, y] }; if (this.tool === "clear") { this.ops = []; this.clearSurface(); } else this.applyOp({ ...op, points: [x, y] }); this.cb.onBegin(this.tool, this.color, this.size, [x, y]); this.cb.onEnd(); return; } this.drawing = true; this.lastX = x; this.lastY = y; this.activeLocal = { id: -1, playerId: "", tool: this.tool, color: this.color, size: this.size, points: [x, y] }; this.ops.push(this.activeLocal); this.strokePath([x, y], this.color, this.tool === "eraser" ? this.size * 1.6 : this.size, this.tool === "eraser"); this.cb.onBegin(this.tool, this.color, this.size, [x, y]); this.pending = []; this.batchTimer = window.setInterval(() => this.flush(), BATCH_MS); }); this.canvas.addEventListener("pointermove", (e) => { if (!this.drawing || !this.activeLocal) return; const events = typeof e.getCoalescedEvents === "function" ? e.getCoalescedEvents() : [e]; for (const ev of events) { const [x, y] = this.toNorm(ev); const dx = x - this.lastX, dy = y - this.lastY; if (dx * dx + dy * dy < 12e-7) continue; this.activeLocal.points.push(x, y); this.pending.push(x, y); this.lastX = x; this.lastY = y; } this.strokePath(this.activeLocal.points, this.color, this.tool === "eraser" ? this.size * 1.6 : this.size, this.tool === "eraser"); }); const end = (e) => { if (!this.drawing) return; this.drawing = false; try { this.canvas.releasePointerCapture(e.pointerId); } catch { } this.flush(); if (this.batchTimer) { clearInterval(this.batchTimer); this.batchTimer = null; } this.activeLocal = null; this.cb.onEnd(); }; this.canvas.addEventListener("pointerup", end); this.canvas.addEventListener("pointercancel", end); } flush() { if (this.pending.length) { this.cb.onPoints(this.pending.slice()); this.pending = []; } } }; // src/client/avatar.ts var AVATAR_COLORS = [ "#f44336", "#ff8f00", "#ffd600", "#4caf50", "#00bcd4", "#2196f3", "#3f51b5", "#9c27b0", "#ec407a", "#8d6e63", "#607d8b", "#7cb342" ]; var SKIN = ["#ffd9b3", "#f2b880", "#c98d5a", "#8c5a33", "#ffe0c4", "#e8b06a"]; function drawAvatar(canvas, seed, size = 80) { const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = size * dpr; canvas.height = size * dpr; canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; const g = canvas.getContext("2d"); if (!g) return; g.scale(dpr, dpr); g.clearRect(0, 0, size, size); const s = size / 80; g.save(); g.scale(s, s); const body = AVATAR_COLORS[seed.color % AVATAR_COLORS.length]; g.beginPath(); g.ellipse(40, 46, 26, 24, 0, 0, Math.PI * 2); g.fillStyle = body; g.fill(); g.lineWidth = 3; g.strokeStyle = "rgba(0,0,0,0.55)"; g.stroke(); g.fillStyle = body; for (const fx of [28, 52]) { g.beginPath(); g.ellipse(fx, 68, 8, 5, 0, 0, Math.PI * 2); g.fill(); g.stroke(); } const skin = SKIN[(seed.color + seed.extra) % SKIN.length]; g.beginPath(); g.ellipse(40, 42, 18, 15, 0, 0, Math.PI * 2); g.fillStyle = skin; g.fill(); const eyeY = 40, eyeDX = 8; g.lineWidth = 2.4; g.strokeStyle = "#222"; g.fillStyle = "#222"; const style = seed.eyes % 6; const eye = (cx, happy, closed) => { if (happy) { g.beginPath(); g.arc(cx, eyeY + 1, 4, Math.PI * 1.1, Math.PI * 1.9); g.stroke(); } else if (closed) { g.beginPath(); g.moveTo(cx - 3.5, eyeY); g.lineTo(cx + 3.5, eyeY); g.stroke(); } else { g.beginPath(); g.arc(cx, eyeY, 3.6, 0, Math.PI * 2); g.fillStyle = "#fff"; g.fill(); g.stroke(); g.beginPath(); g.arc(cx + 1, eyeY + 0.6, 1.7, 0, Math.PI * 2); g.fillStyle = "#222"; g.fill(); } }; switch (style) { case 0: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false); break; // normal case 1: eye(40 - eyeDX, true, false); eye(40 + eyeDX, true, false); break; // happy case 2: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, true); break; // wink case 3: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false); g.lineWidth = 2; g.strokeRect(40 - eyeDX - 6, eyeY - 6, 11, 10); g.strokeRect(40 + eyeDX - 5, eyeY - 6, 11, 10); g.beginPath(); g.moveTo(40 - eyeDX + 5, eyeY - 2); g.lineTo(40 + eyeDX - 5, eyeY - 2); g.stroke(); break; case 4: eye(40 - eyeDX, false, true); eye(40 + eyeDX, false, true); break; // sleepy case 5: for (const cx of [40 - eyeDX, 40 + eyeDX]) { g.beginPath(); g.arc(cx, eyeY, 5, 0, Math.PI * 2); g.fillStyle = "#fff"; g.fill(); g.stroke(); g.beginPath(); g.arc(cx + 1, eyeY + 1, 2.2, 0, Math.PI * 2); g.fillStyle = "#222"; g.fill(); } break; } g.lineWidth = 2.4; g.strokeStyle = "#7a3b2e"; const my = 51; switch (seed.mouth % 6) { case 0: g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); break; // smile case 1: g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.1, Math.PI * 0.9); g.closePath(); g.fillStyle = "#7a3b2e"; g.fill(); g.beginPath(); g.ellipse(40, my + 3, 4, 2.2, 0, 0, Math.PI * 2); g.fillStyle = "#e57373"; g.fill(); break; case 2: g.beginPath(); g.moveTo(34, my); g.quadraticCurveTo(41, my + 5, 47, my - 2); g.stroke(); break; // smirk case 3: g.beginPath(); g.ellipse(40, my, 3.6, 4.6, 0, 0, Math.PI * 2); g.fillStyle = "#7a3b2e"; g.fill(); break; // O case 4: g.beginPath(); g.arc(40, my - 3, 6, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); g.beginPath(); g.ellipse(40, my + 3.5, 3, 3.8, 0, 0, Math.PI * 2); g.fillStyle = "#e57373"; g.fill(); g.stroke(); break; case 5: g.beginPath(); g.arc(40, my + 6, 6, Math.PI * 1.15, Math.PI * 1.85); g.stroke(); break; } g.strokeStyle = "rgba(0,0,0,0.55)"; switch (seed.extra % 6) { case 1: g.fillStyle = "#4e342e"; g.beginPath(); g.moveTo(28, 26); g.quadraticCurveTo(34, 8, 40, 22); g.quadraticCurveTo(46, 8, 52, 26); g.quadraticCurveTo(40, 18, 28, 26); g.fill(); break; case 2: g.fillStyle = "#e53935"; g.beginPath(); g.arc(40, 26, 15, Math.PI, 0); g.closePath(); g.fill(); g.stroke(); g.beginPath(); g.ellipse(52, 26, 9, 4, 0.15, 0, Math.PI * 2); g.fill(); g.stroke(); g.fillStyle = "#fff"; g.beginPath(); g.arc(40, 13, 3, 0, Math.PI * 2); g.fill(); break; case 3: g.beginPath(); g.moveTo(40, 24); g.quadraticCurveTo(42, 12, 40, 8); g.stroke(); g.fillStyle = "#ffd600"; g.beginPath(); g.arc(40, 7, 3.6, 0, Math.PI * 2); g.fill(); g.stroke(); break; case 4: g.fillStyle = "#ec407a"; for (const dir of [-1, 1]) { g.beginPath(); g.moveTo(40 + dir * 6, 20); g.lineTo(40 + dir * 16, 13); g.lineTo(40 + dir * 16, 27); g.closePath(); g.fill(); g.stroke(); } g.beginPath(); g.arc(40, 20, 3.4, 0, Math.PI * 2); g.fill(); g.stroke(); break; case 5: g.fillStyle = "#ffd600"; g.beginPath(); g.moveTo(28, 22); g.lineTo(28, 12); g.lineTo(34, 18); g.lineTo(40, 9); g.lineTo(46, 18); g.lineTo(52, 12); g.lineTo(52, 22); g.closePath(); g.fill(); g.stroke(); break; default: break; } g.restore(); } // src/client/sfx.ts var ctx = null; var muted = false; function ac() { try { if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === "suspended") void ctx.resume(); return ctx; } catch { return null; } } function tone(freq, dur, opts = {}) { if (muted) return; const a = ac(); if (!a) return; const t0 = a.currentTime + (opts.at ?? 0); const osc = a.createOscillator(); const gain = a.createGain(); osc.type = opts.type ?? "sine"; osc.frequency.setValueAtTime(freq, t0); if (opts.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(30, opts.slide), t0 + dur); const v = opts.vol ?? 0.12; gain.gain.setValueAtTime(1e-4, t0); gain.gain.exponentialRampToValueAtTime(v, t0 + 0.012); gain.gain.exponentialRampToValueAtTime(1e-4, t0 + dur); osc.connect(gain).connect(a.destination); osc.start(t0); osc.stop(t0 + dur + 0.02); } var sfx = { setMuted(m) { muted = m; }, get muted() { return muted; }, unlock() { ac(); }, click() { tone(650, 0.05, { type: "square", vol: 0.05 }); }, pop() { tone(880, 0.06, { vol: 0.07 }); }, join() { tone(523, 0.09, { type: "triangle" }); tone(784, 0.13, { at: 0.08, type: "triangle" }); }, leave() { tone(440, 0.09, { type: "triangle" }); tone(294, 0.14, { at: 0.08, type: "triangle" }); }, correct() { [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.13, { at: i * 0.07, type: "triangle", vol: 0.12 })); }, yourTurn() { [392, 523, 659, 784].forEach((f, i) => tone(f, 0.12, { at: i * 0.09, type: "square", vol: 0.07 })); }, tick() { tone(1150, 0.035, { type: "square", vol: 0.05 }); }, reveal() { tone(700, 0.09, { type: "triangle", vol: 0.1, slide: 1e3 }); }, closeGuess() { tone(500, 0.08, { type: "sine", vol: 0.09 }); tone(650, 0.1, { at: 0.07, vol: 0.09 }); }, turnEnd() { tone(330, 0.15, { type: "triangle" }); tone(262, 0.22, { at: 0.13, type: "triangle" }); }, gameEnd() { [392, 523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, 0.18, { at: i * 0.11, type: "triangle", vol: 0.11 })); }, drawStart() { tone(600, 0.08, { type: "square", vol: 0.06, slide: 900 }); }, denied() { tone(220, 0.15, { type: "sawtooth", vol: 0.07, slide: 110 }); }, erase() { tone(300, 0.05, { type: "sawtooth", vol: 0.04, slide: 180 }); } }; // src/client/ui.ts var PALETTE = [ "#ffffff", "#c1c1c1", "#ef130b", "#ff7f00", "#ffcf00", "#64dd17", "#00c853", "#00b8d4", "#2979ff", "#651fff", "#d500f9", "#f50057", "#795548", "#000000", "#616161", "#a30000", "#b35c00", "#8f7200", "#2f7a00", "#00695c", "#006064", "#0d47a1", "#311b92", "#6a0080", "#880e4f", "#4e342e" ]; var BRUSH_SIZES = [6, 14, 26, 42]; function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html !== void 0) e.innerHTML = html; return e; } function $(id) { const e = document.getElementById(id); if (!e) throw new Error(`missing #${id}`); return e; } function toast(text, kind = "info") { const wrap = $("toast-wrap"); const t = el("div", `toast ${kind === "info" ? "" : kind}`); t.textContent = text; wrap.appendChild(t); setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .4s"; }, 2600); setTimeout(() => t.remove(), 3100); } function showScreen(name2) { $("screen-home").classList.toggle("hidden", name2 !== "home"); $("screen-game").classList.toggle("hidden", name2 !== "game"); } var bannerTimer = null; function banner(text, ms = 2400) { const b = $("banner"); b.textContent = text; b.classList.remove("hidden"); if (bannerTimer) clearTimeout(bannerTimer); bannerTimer = window.setTimeout(() => b.classList.add("hidden"), ms); } function confetti(parent, count = 70) { const colors = ["#f44336", "#ff9800", "#ffeb3b", "#4caf50", "#2196f3", "#9c27b0", "#e91e63"]; for (let i = 0; i < count; i++) { const c = el("div", "confetti"); c.style.position = "fixed"; c.style.left = `${Math.random() * 100}vw`; c.style.background = colors[i % colors.length]; c.style.animationDuration = `${1.8 + Math.random() * 2.2}s`; c.style.animationDelay = `${Math.random() * 0.8}s`; c.style.width = `${6 + Math.random() * 8}px`; c.style.height = `${10 + Math.random() * 10}px`; parent.appendChild(c); setTimeout(() => c.remove(), 5200); } } function renderPlayers(players2, opts) { const list = $("players-list"); list.innerHTML = ""; const sorted = players2.slice().sort((a, b) => b.score - a.score); sorted.forEach((p, i) => { const row = el("li", "player-row"); if (p.id === opts.drawerId) row.classList.add("drawing"); if (p.guessed) row.classList.add("guessed"); if (p.id === opts.selfId) row.classList.add("me"); const rank = el("span", "player-rank", `#${i + 1}`); const cv = el("canvas"); drawAvatar(cv, p.avatar, 44); const info = el("div", "player-info"); const name2 = el("div", "player-name"); name2.textContent = p.name + (p.id === opts.selfId ? " (You)" : ""); if (p.id === opts.hostId) name2.insertAdjacentHTML("afterbegin", '\u{1F451}'); if (opts.typingIds.has(p.id)) name2.insertAdjacentHTML("beforeend", ''); const score = el("div", "player-score", `${p.score} pts`); info.appendChild(name2); info.appendChild(score); const status = el("span", "player-status"); if (p.id === opts.drawerId) status.textContent = "\u270F\uFE0F"; else if (p.guessed) status.textContent = "\u2705"; row.appendChild(rank); row.appendChild(cv); row.appendChild(info); row.appendChild(status); if (opts.selfId === opts.hostId && p.id !== opts.selfId) { const kick = el("button", "player-kick", "\u2715"); kick.title = `Kick ${p.name}`; kick.onclick = () => { if (confirm(`Kick ${p.name}?`)) opts.onKick(p.id); }; row.appendChild(kick); } list.appendChild(row); }); } function addChat(opts) { const box = $("chat-messages"); const m = el("div", "chat-msg"); const kind = opts.kind ?? "normal"; if (kind !== "normal") m.classList.add(kind === "info" ? "sys" : kind); if (opts.name) { const n = el("span", "chat-name"); n.textContent = `${opts.name}: `; m.appendChild(n); } m.appendChild(document.createTextNode(opts.text)); box.appendChild(m); while (box.children.length > 200) box.firstChild?.remove(); box.scrollTop = box.scrollHeight; } function setTypingText(text) { $("typing-indicator").textContent = text; } function setRoomCode(code) { $("room-code").textContent = code; } function setRound(text) { $("round-display").textContent = text; } function setWord(mask, opts) { const wd = $("word-display"); wd.innerHTML = ""; if (opts.label) wd.appendChild(el("span", "word-hint-label", opts.label)); for (const ch of mask) { if (ch === " ") { wd.appendChild(el("span", "word-char space")); continue; } const hidden = ch === "_"; const span = el("span", "word-char" + (hidden ? "" : " revealed"), hidden ? " " : ch); wd.appendChild(span); } } function setTimer(left, total) { const clock = $("clock"); if (total <= 1) { $("clock-num").textContent = "\u2013"; clock.classList.remove("urgent"); const hand0 = document.getElementById("clock-hand"); if (hand0) hand0.style.transform = "rotate(0deg)"; return; } $("clock-num").textContent = String(left); const hand = document.getElementById("clock-hand"); if (hand) { const frac = total > 0 ? 1 - left / total : 0; hand.style.transform = `rotate(${frac * 360}deg)`; } clock.classList.toggle("urgent", left <= 15 && left > 0 && total > 20); } function buildTools(cb, initial) { const palette = $("palette"); palette.innerHTML = ""; let activeSwatch = null; for (const color of PALETTE) { const sw = el("div", "color-swatch"); sw.style.background = color; sw.title = color; sw.onclick = () => { activeSwatch?.classList.remove("active"); sw.classList.add("active"); activeSwatch = sw; cb.onColor(color); sfx.click(); }; if (color === initial.color) { sw.classList.add("active"); activeSwatch = sw; } palette.appendChild(sw); } const sizes = $("brush-sizes"); sizes.innerHTML = ""; let activeSize = null; for (const s of BRUSH_SIZES) { const b = el("button", "size-btn"); b.title = `Brush size ${s}`; const dot = el("span", "size-dot"); const d = 4 + s / 42 * 20; dot.style.width = `${d}px`; dot.style.height = `${d}px`; b.appendChild(dot); b.onclick = () => { activeSize?.classList.remove("active"); b.classList.add("active"); activeSize = b; cb.onSize(s); sfx.click(); }; if (s === initial.size) { b.classList.add("active"); activeSize = b; } sizes.appendChild(b); } const toolBtns = { brush: $("tool-brush"), eraser: $("tool-eraser"), fill: $("tool-fill") }; const selectTool = (t) => { for (const [name2, btn] of Object.entries(toolBtns)) btn.classList.toggle("active", name2 === t); }; $("tool-brush").onclick = () => { cb.onTool("brush"); selectTool("brush"); sfx.click(); }; $("tool-eraser").onclick = () => { cb.onTool("eraser"); selectTool("eraser"); sfx.click(); }; $("tool-fill").onclick = () => { cb.onTool("fill"); selectTool("fill"); sfx.click(); }; $("tool-undo").onclick = () => { cb.onUndo(); sfx.click(); }; $("tool-clear").onclick = () => { if (confirm("Clear the whole canvas?")) { cb.onClear(); sfx.click(); } }; selectTool(initial.tool); return { setEnabled(v) { $("tools").classList.toggle("disabled", !v); }, selectTool }; } function showOverlay(content) { const ov = $("overlay"); ov.innerHTML = ""; ov.appendChild(content); ov.classList.remove("hidden"); } function hideOverlay() { $("overlay").classList.add("hidden"); $("overlay").innerHTML = ""; } function overlayCard(cls = "") { return el("div", `overlay-card ${cls}`); } function overlayWaitingChoice(drawerName) { const card = overlayCard(); card.appendChild(el("h2", "", `\u270F\uFE0F ${escapeHtml(drawerName)}`)); card.appendChild(el("div", "overlay-sub", "is choosing a word\u2026")); showOverlay(card); } function overlayWordChoice(words, onPick) { const card = overlayCard(); card.appendChild(el("h2", "", "Choose a word to draw!")); const row = el("div", "word-choice-row"); for (const w of words) { const b = el("button", "word-choice-btn"); b.textContent = w; b.onclick = () => { onPick(w); }; row.appendChild(b); } card.appendChild(row); card.appendChild(el("div", "overlay-sub", "Pick fast \u2014 or a random word is chosen for you!")); showOverlay(card); } function overlayLobby(settings2, code, isHost2, playerCount, cb) { const card = overlayCard("lobby-card"); card.appendChild(el("h2", "", "Waiting for players\u2026")); const codeRow = el("div", "lobby-code-row"); codeRow.appendChild(el("span", "", "Room code:")); const codeEl = el("span", "lobby-code", code); codeEl.title = "Click to copy"; codeEl.onclick = () => { void navigator.clipboard?.writeText(code); toast("Code copied! Share it with friends.", "good"); sfx.pop(); }; codeRow.appendChild(codeEl); card.appendChild(codeRow); const grid = el("div", "settings-grid"); const sel = (label, values, current, suffix, onChange) => { const lab = el("label"); lab.appendChild(el("span", "", label)); const s = el("select"); for (const v of values) { const o = el("option", "", `${v}${suffix}`); o.value = String(v); s.appendChild(o); } s.value = String(current); s.disabled = !isHost2; s.onchange = () => onChange(parseInt(s.value, 10)); lab.appendChild(s); return lab; }; grid.appendChild(sel("Rounds", [2, 3, 4, 5, 6, 7, 8], settings2.rounds, "", (v) => cb.onSettings({ rounds: v }))); grid.appendChild(sel("Draw time (s)", [30, 40, 50, 60, 70, 80, 90, 100, 120, 150, 180], settings2.drawTime, " s", (v) => cb.onSettings({ drawTime: v }))); grid.appendChild(sel("Max players", [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], settings2.maxPlayers, "", (v) => cb.onSettings({ maxPlayers: v }))); const pubLab = el("label", "checkbox-label"); const pub = el("input"); pub.type = "checkbox"; pub.checked = settings2.isPublic; pub.disabled = !isHost2; pub.onchange = () => cb.onSettings({ isPublic: pub.checked }); pubLab.appendChild(pub); pubLab.appendChild(el("span", "", "Public room (listed in lobby browser)")); grid.appendChild(pubLab); const cwLab = el("label", "full"); cwLab.appendChild(el("span", "", "Custom words (comma separated, optional)")); const cw = el("textarea"); cw.placeholder = "e.g. lightsaber, hogwarts, pizza tower\u2026"; cw.value = settings2.customWords; cw.disabled = !isHost2; cw.onchange = () => cb.onSettings({ customWords: cw.value }); cwLab.appendChild(cw); grid.appendChild(cwLab); const cwoLab = el("label", "checkbox-label full"); const cwo = el("input"); cwo.type = "checkbox"; cwo.checked = settings2.customOnly; cwo.disabled = !isHost2; cwo.onchange = () => cb.onSettings({ customOnly: cwo.checked }); cwoLab.appendChild(cwo); cwoLab.appendChild(el("span", "", "Use custom words only")); grid.appendChild(cwoLab); card.appendChild(grid); const actions = el("div", "lobby-actions"); if (isHost2) { const start = el("button", "btn btn-green btn-big", "\u25B6 Start Game"); if (playerCount < 2) start.setAttribute("disabled", "true"); start.onclick = () => { cb.onStart(); sfx.click(); }; actions.appendChild(start); } card.appendChild(actions); if (!isHost2) card.appendChild(el("div", "waiting-text", "Waiting for the host to start the game\u2026")); else if (playerCount < 2) card.appendChild(el("div", "waiting-text", "Invite friends with the room code \u2014 you need at least 2 players!")); showOverlay(card); } function overlayTurnEnd(word, reason, scores, selfId2) { const card = overlayCard(); card.appendChild(el("h2", "", reason === "time up" ? "\u23F0 Time's up!" : reason === "everyone guessed" ? "\u{1F389} Everyone guessed it!" : "Turn over")); card.appendChild(el("div", "overlay-sub", "The word was")); card.appendChild(el("div", "reveal-word", escapeHtml(word))); const recap = el("div", "score-recap"); for (const s of scores) { const row = el("div", "score-recap-row"); const name2 = el("span", "", escapeHtml(s.name) + (s.id === selfId2 ? " (You)" : "")); const delta = el("span", "delta", s.delta > 0 ? `+${s.delta}` : "\u2014"); row.appendChild(name2); row.appendChild(delta); recap.appendChild(row); } card.appendChild(recap); showOverlay(card); } function overlayGameEnd(scores, selfId2) { const card = overlayCard(); card.appendChild(el("h2", "", "\u{1F3C6} Game Over!")); const medals = ["\u{1F947}", "\u{1F948}", "\u{1F949}"]; const podium = el("div", "podium"); scores.slice(0, 3).forEach((s, i) => { const slot = el("div", `podium-slot podium-${i + 1}`); const cv = el("canvas"); drawAvatar(cv, s.avatar, 64); slot.appendChild(el("div", "medal", medals[i])); slot.appendChild(cv); slot.appendChild(el("div", "podium-name", escapeHtml(s.name) + (s.id === selfId2 ? " (You)" : ""))); slot.appendChild(el("div", "podium-score", `${s.score} pts`)); slot.appendChild(el("div", "podium-block", String(i + 1))); podium.appendChild(slot); }); card.appendChild(podium); const table = el("div", "leaderboard-table"); scores.forEach((s, i) => { const row = el("div", "leaderboard-row" + (i === 0 ? " winner" : "")); row.appendChild(el("span", "leaderboard-rank", `#${i + 1}`)); row.appendChild(el("span", "leaderboard-name", escapeHtml(s.name))); row.appendChild(el("span", "", `${s.score} pts`)); table.appendChild(row); }); card.appendChild(table); card.appendChild(el("div", "overlay-sub", "Returning to lobby\u2026")); showOverlay(card); confetti(document.body); } function renderRoomList(rooms, onJoin) { const list = $("rooms-list"); list.innerHTML = ""; if (!rooms.length) { list.appendChild(el("div", "rooms-empty", "No public rooms yet \u2014 create one!")); return; } for (const r of rooms) { const row = el("div", "room-row"); const info = el("div", "room-info"); info.appendChild(el("div", "room-code", `#${r.code}`)); info.appendChild(el("div", "room-meta", `${r.players}/${r.maxPlayers} players \xB7 ${r.phase === "lobby" ? "in lobby" : "playing"} \xB7 host ${escapeHtml(r.hostName)}`)); const join = el("button", "btn btn-green btn-small", "Join"); join.disabled = r.players >= r.maxPlayers; join.onclick = () => onJoin(r.code); row.appendChild(info); row.appendChild(join); list.appendChild(row); } } function escapeHtml(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); } // src/client/main.ts var net = new Net(); var selfId = ""; var name = localStorage.getItem("skribble_name") ?? ""; var avatar = JSON.parse(localStorage.getItem("skribble_avatar") ?? "null") ?? randomAvatar(); var inRoom = false; var roomCode = ""; var hostId = ""; var phase = "lobby"; var settings = { ...DEFAULT_SETTINGS }; var players = []; var drawerId = null; var round = 0; var totalRounds = settings.rounds; var timerTotal = 80; var lastTick = -1; var typingIds = /* @__PURE__ */ new Set(); var typingTimers = /* @__PURE__ */ new Map(); var lobbyCtx; var engine = new DrawEngine(document.getElementById("draw-canvas")); var toolsApi; var isHost = () => selfId === hostId; var isDrawer = () => selfId === drawerId && phase === "drawing"; function saveIdentity() { localStorage.setItem("skribble_name", name); localStorage.setItem("skribble_avatar", JSON.stringify(avatar)); } function sendHello() { net.send({ t: "hello", name: name || `Player${Math.floor(Math.random() * 1e3)}`, avatar }); } function renderPlayers2() { renderPlayers(players, { selfId, hostId, drawerId, typingIds, onKick: (id) => net.send({ t: "kick", playerId: id }) }); } function playerName(id) { return players.find((p) => p.id === id)?.name ?? "???"; } function refreshLobbyOverlay() { if (phase !== "lobby" || !inRoom) return; overlayLobby(settings, roomCode, isHost(), players.length, lobbyCtx); } function setTimer2(left) { setTimer(left, timerTotal); if (phase === "drawing" && left <= 5 && left >= 1 && left !== lastTick) { lastTick = left; sfx.tick(); } } net.onOpen = () => { sendHello(); }; net.onClose = () => { toast("Disconnected from server. Reload to reconnect.", "error"); }; net.onMessage = (msg) => { switch (msg.t) { case "welcome": selfId = msg.id; net.send({ t: "list_rooms" }); break; case "room_list": renderRoomList(msg.rooms, (code) => joinRoom(code)); break; case "join_error": toast(msg.reason, "error"); sfx.denied(); break; case "joined": { const r = msg.room; inRoom = true; roomCode = r.code; hostId = r.hostId; phase = r.phase; settings = r.settings; players = r.players; drawerId = r.drawerId; round = r.round; totalRounds = r.totalRounds; timerTotal = r.timeLeft || settings.drawTime; showScreen("game"); requestAnimationFrame(fitCanvas); setRoomCode(r.code); setRound(phase === "lobby" ? "Lobby" : `Round ${round} of ${totalRounds}`); engine.reset(r.ops ?? []); renderPlayers2(); toast(`Joined room #${r.code}`, "good"); sfx.join(); applyPhaseUI(r.word, r.mask, r.timeLeft); break; } case "player_joined": players.push(msg.player); renderPlayers2(); refreshLobbyOverlay(); sfx.join(); break; case "player_left": players = players.filter((p) => p.id !== msg.id); hostId = msg.hostId; renderPlayers2(); refreshLobbyOverlay(); sfx.leave(); break; case "host": hostId = msg.id; renderPlayers2(); refreshLobbyOverlay(); if (msg.id === selfId) toast("You are now the host \u{1F451}"); break; case "settings": settings = msg.settings; refreshLobbyOverlay(); break; case "chat": addChat({ name: msg.name, text: msg.text }); sfx.pop(); break; case "chat_sys": addChat({ text: msg.text, kind: msg.kind }); if (msg.kind === "good") sfx.correct(); else if (msg.kind === "close") sfx.closeGuess(); else if (msg.kind === "bad") sfx.leave(); break; case "score_update": players = msg.players; renderPlayers2(); break; case "typing": { typingIds.add(msg.id); setTypingText(`${playerName(msg.id)} is typing\u2026`); const prev = typingTimers.get(msg.id); if (prev) clearTimeout(prev); typingTimers.set(msg.id, window.setTimeout(() => { typingIds.delete(msg.id); setTypingText(""); renderPlayers2(); }, 2500)); renderPlayers2(); break; } case "game_starting": round = msg.round; totalRounds = msg.totalRounds; setRound(`Round ${round} of ${totalRounds}`); hideOverlay(); banner(`\u{1F3AC} Round ${round} of ${totalRounds}`); drawerId = null; engine.canDraw = false; toolsApi.setEnabled(false); break; case "word_choice": phase = "wordChoice"; drawerId = msg.drawerId; timerTotal = msg.timeLeft; setTimer(msg.timeLeft, timerTotal); renderPlayers2(); if (msg.drawerId !== selfId) { overlayWaitingChoice(msg.drawerName); setWord("", { isDrawer: false, label: `${msg.drawerName} is choosing a word` }); } break; case "choose_words": sfx.yourTurn(); overlayWordChoice(msg.words, (w) => { net.send({ t: "pick_word", word: w }); sfx.click(); hideOverlay(); banner(`You picked: ${w.toUpperCase()}`); }); break; case "draw_start": { phase = "drawing"; drawerId = msg.drawerId; timerTotal = msg.timeLeft; lastTick = -1; round = msg.round; totalRounds = msg.totalRounds; hideOverlay(); setRound(`Round ${round} of ${totalRounds}`); engine.reset([]); const mine = msg.drawerId === selfId; engine.canDraw = mine; toolsApi.setEnabled(mine); document.getElementById("draw-canvas").classList.toggle("no-draw", !mine); if (mine && msg.word) { setWord(msg.word, { isDrawer: true, label: "DRAW THIS:" }); banner(`\u270F\uFE0F Your turn! Draw: ${msg.word.toUpperCase()}`, 3e3); sfx.drawStart(); } else { setWord(msg.mask, { isDrawer: false, label: "GUESS:" }); banner(`\u270F\uFE0F ${playerName(msg.drawerId)} is drawing!`); sfx.pop(); } setTimer(msg.timeLeft, timerTotal); renderPlayers2(); break; } case "hint": if (!isDrawer()) { setWord(msg.mask, { isDrawer: false, label: "GUESS:" }); sfx.reveal(); } break; case "timer": setTimer2(msg.timeLeft); break; case "turn_end": phase = "turnEnd"; engine.canDraw = false; toolsApi.setEnabled(false); overlayTurnEnd(msg.word, msg.reason, msg.scores, selfId); sfx.turnEnd(); for (const s of msg.scores) { const p = players.find((x) => x.id === s.id); if (p) p.score = s.score; } renderPlayers2(); break; case "game_end": phase = "gameEnd"; overlayGameEnd(msg.scores, selfId); sfx.gameEnd(); for (const s of msg.scores) { const p = players.find((x) => x.id === s.id); if (p) p.score = s.score; } renderPlayers2(); break; case "back_to_lobby": phase = "lobby"; drawerId = null; engine.canDraw = false; toolsApi.setEnabled(false); engine.reset([]); setRound("Lobby"); setWord("", { isDrawer: false, label: "" }); setTimer(0, 1); for (const p of players) p.guessed = false; renderPlayers2(); refreshLobbyOverlay(); break; case "draw_begin": engine.remoteBegin(msg.op); break; case "draw_points": engine.remotePoints(msg.id, msg.points); break; case "draw_end": engine.remoteEnd(msg.id); break; case "undo": engine.undo(msg.opId); break; case "kicked": toast(`You were removed: ${msg.reason}`, "error"); leaveToHome(); break; case "pong": break; } }; function applyPhaseUI(word, mask, timeLeft) { engine.canDraw = false; toolsApi.setEnabled(false); switch (phase) { case "lobby": refreshLobbyOverlay(); setWord("", { isDrawer: false }); break; case "wordChoice": if (drawerId && drawerId !== selfId) overlayWaitingChoice(playerName(drawerId)); break; case "drawing": { const mine = word != null; engine.canDraw = mine; toolsApi.setEnabled(mine); document.getElementById("draw-canvas").classList.toggle("no-draw", !mine); if (mine) setWord(word, { isDrawer: true, label: "DRAW THIS:" }); else setWord(mask, { isDrawer: false, label: "GUESS:" }); hideOverlay(); break; } case "turnEnd": case "gameEnd": break; } if (timeLeft) setTimer(timeLeft, timerTotal); } function joinRoom(code) { saveIdentity(); sendHello(); net.send({ t: "join_room", code }); } function initHome() { const nameInput = document.getElementById("name-input"); nameInput.value = name; nameInput.oninput = () => { name = nameInput.value; saveIdentity(); }; const preview = document.getElementById("avatar-preview"); drawAvatar(preview, avatar, 80); document.getElementById("btn-avatar-shuffle").onclick = () => { avatar = randomAvatar(); drawAvatar(preview, avatar, 80); saveIdentity(); sfx.pop(); }; document.getElementById("btn-play").onclick = () => { saveIdentity(); sendHello(); net.send({ t: "quick_join" }); sfx.click(); }; document.getElementById("btn-create-private").onclick = () => { saveIdentity(); sendHello(); net.send({ t: "create_room", isPublic: false }); sfx.click(); }; const codeInput = document.getElementById("code-input"); document.getElementById("btn-join-code").onclick = () => { if (codeInput.value.trim().length !== 4) { toast("Enter the 4-letter room code", "error"); return; } joinRoom(codeInput.value.trim()); sfx.click(); }; codeInput.onkeydown = (e) => { if (e.key === "Enter") document.getElementById("btn-join-code").click(); }; document.getElementById("btn-refresh-rooms").onclick = () => { net.send({ t: "list_rooms" }); sfx.click(); }; window.setInterval(() => { if (!inRoom && net.connected) net.send({ t: "list_rooms" }); }, 4e3); } function leaveToHome() { inRoom = false; phase = "lobby"; players = []; drawerId = null; typingIds.clear(); showScreen("home"); hideOverlay(); net.send({ t: "list_rooms" }); } function initGame() { toolsApi = buildTools({ onTool: (t) => { engine.tool = t; }, onColor: (c) => { engine.color = c; if (engine.tool === "eraser") { engine.tool = "brush"; toolsApi.selectTool("brush"); } }, onSize: (s) => { engine.size = s; }, onUndo: () => { if (isDrawer()) net.send({ t: "undo" }); }, onClear: () => { if (!isDrawer()) return; engine.reset([]); net.send({ t: "draw_begin", tool: "clear", color: engine.color, size: engine.size, points: [0.5, 0.5] }); net.send({ t: "draw_end" }); } }, { tool: "brush", color: "#000000", size: 14 }); toolsApi.setEnabled(false); engine.cb = { onBegin: (tool, color, size, points) => net.send({ t: "draw_begin", tool, color, size, points }), onPoints: (points) => net.send({ t: "draw_points", points }), onEnd: () => net.send({ t: "draw_end" }) }; engine.onLocalDraw = () => { }; const chatInput = document.getElementById("chat-input"); let lastTypingSent = 0; chatInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { const text = chatInput.value.trim(); if (text) net.send({ t: "chat", text }); chatInput.value = ""; } }); chatInput.addEventListener("input", () => { const now = Date.now(); if (now - lastTypingSent > 1500 && chatInput.value.trim()) { lastTypingSent = now; net.send({ t: "typing" }); } }); document.getElementById("btn-leave").onclick = () => { net.send({ t: "leave_room" }); sfx.click(); leaveToHome(); }; document.getElementById("btn-sound").onclick = (e) => { sfx.setMuted(!sfx.muted); e.currentTarget.textContent = sfx.muted ? "\u{1F507}" : "\u{1F50A}"; if (!sfx.muted) sfx.pop(); }; document.getElementById("room-code-chip").onclick = () => { void navigator.clipboard?.writeText(roomCode); toast("Room code copied!", "good"); sfx.pop(); }; document.addEventListener("keydown", (e) => { if (e.target?.tagName === "INPUT" || e.target?.tagName === "TEXTAREA") return; if (!inRoom) return; const k = e.key.toLowerCase(); if (k === "b") { engine.tool = "brush"; toolsApi.selectTool("brush"); } else if (k === "e") { engine.tool = "eraser"; toolsApi.selectTool("eraser"); } else if (k === "f") { engine.tool = "fill"; toolsApi.selectTool("fill"); } else if (k === "z" && (e.ctrlKey || e.metaKey) && isDrawer()) { e.preventDefault(); net.send({ t: "undo" }); } }); lobbyCtx = { onSettings: (s) => net.send({ t: "set_settings", settings: s }), onStart: () => net.send({ t: "start_game" }) }; } function fitCanvas() { const wrap = document.getElementById("canvas-wrap"); const cv = document.getElementById("draw-canvas"); if (!wrap.clientWidth) return; const scale = Math.min(wrap.clientWidth / 1e3, wrap.clientHeight / 750); cv.style.width = `${Math.floor(1e3 * scale)}px`; cv.style.height = `${Math.floor(750 * scale)}px`; } window.addEventListener("resize", fitCanvas); initHome(); initGame(); showScreen("home"); document.addEventListener("pointerdown", () => sfx.unlock(), { once: true }); net.connect(); })(); //# sourceMappingURL=bundle.js.map