Files
2026-07-18 20:41:22 -05:00

1553 lines
49 KiB
JavaScript

(() => {
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", '<span title="Host">\u{1F451}</span>');
if (opts.typingIds.has(p.id)) name2.insertAdjacentHTML("beforeend", '<span class="typing-dots" style="color:#42a5f5"></span>');
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 ? "&nbsp;" : 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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