1553 lines
49 KiB
JavaScript
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 ? " " : 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
|