- Footer: add site-footer with one-liner + Cloud Host branding linking to cloudhost.es (mobile only, hidden on desktop) - Auto-pause: game pauses automatically after AUTOPAUSE_DELAY_MS (default 60s) with no viewers connected; auto-resumes when first viewer connects; shows "Esperando espectadores…" in the UI - Redsys: new /pregunta page lets viewers pay 1€ via Redsys to submit a fill-in question; submitted questions are used as prompts in the next round instead of AI generation; new redsys.ts implements HMAC_SHA256_V1 signing (3DES key derivation + HMAC-SHA256); notification endpoint at /api/redsys/notificacion handles server-to-server confirmation - db.ts: questions table with createPendingQuestion / markQuestionPaid / getNextPendingQuestion / markQuestionUsed - .env.sample: added AUTOPAUSE_DELAY_MS, PUBLIC_URL, REDSYS_* vars Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
710 lines
20 KiB
TypeScript
710 lines
20 KiB
TypeScript
type Model = { id: string; name: string };
|
|
type TaskInfo = {
|
|
model: Model;
|
|
startedAt: number;
|
|
finishedAt?: number;
|
|
result?: string;
|
|
error?: string;
|
|
};
|
|
type VoteInfo = {
|
|
voter: Model;
|
|
startedAt: number;
|
|
finishedAt?: number;
|
|
votedFor?: Model;
|
|
error?: boolean;
|
|
};
|
|
type RoundState = {
|
|
num: number;
|
|
phase: "prompting" | "answering" | "voting" | "done";
|
|
prompter: Model;
|
|
promptTask: TaskInfo;
|
|
prompt?: string;
|
|
contestants: [Model, Model];
|
|
answerTasks: [TaskInfo, TaskInfo];
|
|
votes: VoteInfo[];
|
|
scoreA?: number;
|
|
scoreB?: number;
|
|
viewerVotesA?: number;
|
|
viewerVotesB?: number;
|
|
viewerVotingEndsAt?: number;
|
|
};
|
|
type GameState = {
|
|
lastCompleted: RoundState | null;
|
|
active: RoundState | null;
|
|
scores: Record<string, number>;
|
|
viewerScores: Record<string, number>;
|
|
done: boolean;
|
|
isPaused: boolean;
|
|
autoPaused?: boolean;
|
|
generation: number;
|
|
};
|
|
type StateMessage = {
|
|
type: "state";
|
|
data: GameState;
|
|
totalRounds: number;
|
|
viewerCount: number;
|
|
version?: string;
|
|
};
|
|
type ViewerCountMessage = {
|
|
type: "viewerCount";
|
|
viewerCount: number;
|
|
};
|
|
type ServerMessage = StateMessage | ViewerCountMessage;
|
|
|
|
const MODEL_COLORS: Record<string, string> = {
|
|
"Gemini 3.1 Pro": "#4285F4",
|
|
"Kimi K2": "#00E599",
|
|
"DeepSeek 3.2": "#4D6BFE",
|
|
"GLM-5": "#1F63EC",
|
|
"GPT-5.2": "#10A37F",
|
|
"Opus 4.6": "#D97757",
|
|
"Sonnet 4.6": "#D97757",
|
|
"Grok 4.1": "#FFFFFF",
|
|
"MiniMax 2.5": "#FF3B30",
|
|
};
|
|
|
|
const WIDTH = 1920;
|
|
const HEIGHT = 1080;
|
|
|
|
const canvas = document.getElementById("broadcast-canvas") as HTMLCanvasElement;
|
|
const statusEl = document.getElementById("broadcast-status") as HTMLDivElement;
|
|
|
|
function get2dContext(el: HTMLCanvasElement): CanvasRenderingContext2D {
|
|
const context = el.getContext("2d");
|
|
if (!context) throw new Error("2D canvas context unavailable");
|
|
return context;
|
|
}
|
|
|
|
const ctx = get2dContext(canvas);
|
|
|
|
let state: GameState | null = null;
|
|
let totalRounds: number | null = null;
|
|
let viewerCount = 0;
|
|
let connected = false;
|
|
let ws: WebSocket | null = null;
|
|
let reconnectTimer: number | null = null;
|
|
let lastMessageAt = 0;
|
|
let knownVersion: string | null = null;
|
|
|
|
function getColor(name: string): string {
|
|
return MODEL_COLORS[name] ?? "#aeb6d6";
|
|
}
|
|
|
|
function getLogoUrl(name: string): string | null {
|
|
if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
|
|
if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
|
|
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
|
|
if (name.includes("GLM")) return "/assets/logos/glm.svg";
|
|
if (name.includes("GPT")) return "/assets/logos/openai.svg";
|
|
if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg";
|
|
if (name.includes("Grok")) return "/assets/logos/grok.svg";
|
|
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
|
|
return null;
|
|
}
|
|
|
|
const logoCache: Record<string, HTMLImageElement> = {};
|
|
function drawModelLogo(name: string, x: number, y: number, size: number): boolean {
|
|
const url = getLogoUrl(name);
|
|
if (!url) return false;
|
|
if (!logoCache[url]) {
|
|
const img = new Image();
|
|
img.src = url;
|
|
logoCache[url] = img;
|
|
}
|
|
const img = logoCache[url];
|
|
if (img.complete && img.naturalHeight !== 0) {
|
|
ctx.drawImage(img, x, y, size, size);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function setupWebSocket() {
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
connected = true;
|
|
setStatus("WS connected");
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
connected = false;
|
|
setStatus("WS reconnecting...");
|
|
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = window.setTimeout(setupWebSocket, 1_000);
|
|
};
|
|
|
|
ws.onmessage = (e) => {
|
|
try {
|
|
const msg = JSON.parse(String(e.data)) as ServerMessage;
|
|
if (msg.type === "state") {
|
|
if (msg.version) {
|
|
if (!knownVersion) knownVersion = msg.version;
|
|
else if (knownVersion !== msg.version) return location.reload();
|
|
}
|
|
state = msg.data;
|
|
totalRounds =
|
|
Number.isFinite(msg.totalRounds) && msg.totalRounds >= 0
|
|
? msg.totalRounds
|
|
: null;
|
|
viewerCount = msg.viewerCount;
|
|
lastMessageAt = Date.now();
|
|
} else if (msg.type === "viewerCount") {
|
|
viewerCount = msg.viewerCount;
|
|
}
|
|
} catch {
|
|
// Ignore malformed spectator payloads.
|
|
}
|
|
};
|
|
}
|
|
|
|
function setStatus(value: string) {
|
|
statusEl.textContent = value;
|
|
}
|
|
|
|
function roundRect(
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
r: number,
|
|
fillStyle: string,
|
|
) {
|
|
const p = new Path2D();
|
|
p.moveTo(x + r, y);
|
|
p.lineTo(x + w - r, y);
|
|
p.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
p.lineTo(x + w, y + h - r);
|
|
p.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
p.lineTo(x + r, y + h);
|
|
p.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
p.lineTo(x, y + r);
|
|
p.quadraticCurveTo(x, y, x + r, y);
|
|
ctx.fillStyle = fillStyle;
|
|
ctx.fill(p);
|
|
}
|
|
|
|
function textLines(
|
|
text: string,
|
|
maxWidth: number,
|
|
font: string,
|
|
maxLines = 3,
|
|
): string[] {
|
|
ctx.font = font;
|
|
const words = text.split(/\s+/).filter(Boolean);
|
|
const lines: string[] = [];
|
|
let current = "";
|
|
const maxChunkLength = 1;
|
|
|
|
const splitWordToFit = (word: string): string[] => {
|
|
if (!word) return [];
|
|
if (ctx.measureText(word).width <= maxWidth) return [word];
|
|
|
|
const pieces: string[] = [];
|
|
let remaining = word;
|
|
while (remaining.length > 0) {
|
|
let low = maxChunkLength;
|
|
let high = remaining.length;
|
|
let best = maxChunkLength;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const candidate = remaining.slice(0, mid);
|
|
if (ctx.measureText(candidate).width <= maxWidth) {
|
|
best = mid;
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
|
|
pieces.push(remaining.slice(0, best));
|
|
remaining = remaining.slice(best);
|
|
}
|
|
|
|
return pieces;
|
|
};
|
|
|
|
for (const word of words) {
|
|
const segments = splitWordToFit(word);
|
|
let isFirstSegment = true;
|
|
for (const segment of segments) {
|
|
const prefix = isFirstSegment && current ? " " : "";
|
|
const candidate = current ? `${current}${prefix}${segment}` : segment;
|
|
if (ctx.measureText(candidate).width <= maxWidth) {
|
|
current = candidate;
|
|
isFirstSegment = false;
|
|
continue;
|
|
}
|
|
if (current) lines.push(current);
|
|
current = segment;
|
|
isFirstSegment = false;
|
|
if (lines.length >= maxLines - 1) break;
|
|
}
|
|
if (lines.length >= maxLines - 1) break;
|
|
}
|
|
|
|
if (current) {
|
|
lines.push(current);
|
|
}
|
|
|
|
if (lines.length > maxLines) {
|
|
lines.length = maxLines;
|
|
}
|
|
|
|
if (words.length > 0 && lines.length === maxLines) {
|
|
const last = lines[maxLines - 1] ?? "";
|
|
if (ctx.measureText(last).width > maxWidth) {
|
|
let trimmed = last;
|
|
while (trimmed.length > 3 && ctx.measureText(`${trimmed}...`).width > maxWidth) {
|
|
trimmed = trimmed.slice(0, -1);
|
|
}
|
|
lines[maxLines - 1] = `${trimmed}...`;
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
function drawTextBlock(
|
|
text: string,
|
|
x: number,
|
|
y: number,
|
|
maxWidth: number,
|
|
lineHeight: number,
|
|
font: string,
|
|
color: string,
|
|
maxLines: number,
|
|
) {
|
|
const lines = textLines(text, maxWidth, font, maxLines);
|
|
ctx.font = font;
|
|
ctx.fillStyle = color;
|
|
lines.forEach((line, idx) => {
|
|
ctx.fillText(line, x, y + idx * lineHeight);
|
|
});
|
|
}
|
|
|
|
function drawHeader() {
|
|
ctx.fillStyle = "#0a0a0a";
|
|
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
|
|
|
ctx.font = '700 40px "Inter", sans-serif';
|
|
ctx.fillStyle = "#ededed";
|
|
ctx.fillText("argument.es", 48, 76);
|
|
|
|
}
|
|
|
|
function drawScoreboardSection(
|
|
entries: [string, number][],
|
|
label: string,
|
|
startY: number,
|
|
entryHeight: number,
|
|
) {
|
|
const maxScore = entries[0]?.[1] || 1;
|
|
|
|
// Section label
|
|
ctx.font = '700 13px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#555";
|
|
ctx.fillText(label, WIDTH - 348, startY);
|
|
|
|
// Divider line under label
|
|
ctx.fillStyle = "#1c1c1c";
|
|
ctx.fillRect(WIDTH - 348, startY + 8, 296, 1);
|
|
|
|
entries.forEach(([name, score], index) => {
|
|
const y = startY + 20 + index * entryHeight;
|
|
const color = getColor(name);
|
|
const pct = maxScore > 0 ? score / maxScore : 0;
|
|
|
|
ctx.font = '600 16px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#555";
|
|
const rank = index === 0 && score > 0 ? "👑" : String(index + 1);
|
|
ctx.fillText(rank, WIDTH - 348, y + 18);
|
|
|
|
ctx.font = '600 16px "Inter", sans-serif';
|
|
ctx.fillStyle = color;
|
|
const nameText = name.length > 18 ? `${name.slice(0, 18)}...` : name;
|
|
|
|
const drewLogo = drawModelLogo(name, WIDTH - 310, y + 4, 20);
|
|
if (drewLogo) {
|
|
ctx.fillText(nameText, WIDTH - 310 + 26, y + 18);
|
|
} else {
|
|
ctx.fillText(nameText, WIDTH - 310, y + 18);
|
|
}
|
|
|
|
roundRect(WIDTH - 310, y + 30, 216, 3, 2, "#1c1c1c");
|
|
if (pct > 0) {
|
|
roundRect(WIDTH - 310, y + 30, Math.max(6, 216 * pct), 3, 2, color);
|
|
}
|
|
|
|
ctx.font = '700 16px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#666";
|
|
const scoreText = String(score);
|
|
const scoreWidth = ctx.measureText(scoreText).width;
|
|
ctx.fillText(scoreText, WIDTH - 48 - scoreWidth, y + 18);
|
|
});
|
|
}
|
|
|
|
function drawScoreboard(scores: Record<string, number>, viewerScores: Record<string, number>) {
|
|
const modelEntries = Object.entries(scores).sort((a, b) => b[1] - a[1]) as [string, number][];
|
|
const viewerEntries = Object.entries(viewerScores).sort((a, b) => b[1] - a[1]) as [string, number][];
|
|
|
|
roundRect(WIDTH - 380, 0, 380, HEIGHT, 0, "#111");
|
|
ctx.fillStyle = "#1c1c1c";
|
|
ctx.fillRect(WIDTH - 380, 0, 1, HEIGHT);
|
|
|
|
ctx.font = '700 18px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#888";
|
|
ctx.fillText("STANDINGS", WIDTH - 348, 76);
|
|
|
|
const entryHeight = 52;
|
|
drawScoreboardSection(modelEntries, "AI JUDGES", 110, entryHeight);
|
|
|
|
const viewerStartY = 110 + 28 + modelEntries.length * entryHeight + 16;
|
|
drawScoreboardSection(viewerEntries, "VIEWERS", viewerStartY, entryHeight);
|
|
}
|
|
|
|
function drawRound(round: RoundState) {
|
|
const mainW = WIDTH - 380;
|
|
|
|
let phaseLabel =
|
|
(round.phase === "prompting"
|
|
? "Writing prompt"
|
|
: round.phase === "answering"
|
|
? "Answering"
|
|
: round.phase === "voting"
|
|
? "Judges voting"
|
|
: "Complete"
|
|
).toUpperCase();
|
|
|
|
// Append countdown during voting phase
|
|
let countdownSeconds = 0;
|
|
if (round.phase === "voting" && round.viewerVotingEndsAt) {
|
|
countdownSeconds = Math.max(0, Math.ceil((round.viewerVotingEndsAt - Date.now()) / 1000));
|
|
}
|
|
|
|
ctx.font = '700 22px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#ededed";
|
|
const totalText = totalRounds !== null ? `/${totalRounds}` : "";
|
|
ctx.fillText(`Round ${round.num}${totalText}`, 64, 150);
|
|
|
|
ctx.fillStyle = "#888";
|
|
const labelWidth = ctx.measureText(phaseLabel).width;
|
|
ctx.fillText(phaseLabel, mainW - 64 - labelWidth, 150);
|
|
|
|
if (countdownSeconds > 0) {
|
|
const countdownText = `${countdownSeconds}S`;
|
|
ctx.fillStyle = "#ededed";
|
|
const cdWidth = ctx.measureText(countdownText).width;
|
|
ctx.fillText(countdownText, mainW - 64 - labelWidth - cdWidth - 12, 150);
|
|
}
|
|
|
|
ctx.font = '600 18px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#888";
|
|
const promptedText = "PROMPTED BY ";
|
|
ctx.fillText(promptedText, 64, 210);
|
|
|
|
const pTw = ctx.measureText(promptedText).width;
|
|
ctx.fillStyle = getColor(round.prompter.name);
|
|
const drewPLogo = drawModelLogo(round.prompter.name, 64 + pTw, 210 - 14, 20);
|
|
|
|
if (drewPLogo) {
|
|
ctx.fillText(round.prompter.name.toUpperCase(), 64 + pTw + 24, 210);
|
|
} else {
|
|
ctx.fillText(round.prompter.name.toUpperCase(), 64 + pTw, 210);
|
|
}
|
|
|
|
const promptText =
|
|
round.prompt ??
|
|
(round.phase === "prompting" ? "Generating prompt..." : "Prompt unavailable");
|
|
|
|
const promptFont = '400 56px "DM Serif Display", serif';
|
|
const promptLineHeight = 72;
|
|
const promptMaxLines = 3;
|
|
const promptMaxWidth = mainW - 120;
|
|
const promptLines = textLines(promptText, promptMaxWidth, promptFont, promptMaxLines);
|
|
const promptTextHeight = promptLines.length * promptLineHeight;
|
|
const promptBaselineY = 262;
|
|
const promptBarY = promptBaselineY - 44;
|
|
|
|
ctx.fillStyle = getColor(round.prompter.name);
|
|
ctx.fillRect(64, promptBarY, 4, promptTextHeight + 6);
|
|
|
|
drawTextBlock(
|
|
promptText,
|
|
80,
|
|
promptBaselineY,
|
|
promptMaxWidth,
|
|
promptLineHeight,
|
|
promptFont,
|
|
round.prompt ? "#ededed" : "#444",
|
|
promptMaxLines,
|
|
);
|
|
|
|
if (round.phase !== "prompting") {
|
|
const [taskA, taskB] = round.answerTasks;
|
|
const cardW = (mainW - 160) / 2;
|
|
const cardY = promptBarY + promptTextHeight + 6 + 32;
|
|
const cardH = HEIGHT - cardY - 40;
|
|
drawContestantCard(taskA, 64, cardY, cardW, cardH, round);
|
|
drawContestantCard(taskB, 64 + cardW + 32, cardY, cardW, cardH, round);
|
|
}
|
|
}
|
|
|
|
function drawContestantCard(
|
|
task: TaskInfo,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
round: RoundState,
|
|
) {
|
|
const [a, b] = round.contestants;
|
|
let votesA = 0;
|
|
let votesB = 0;
|
|
const taskVoters: VoteInfo[] = [];
|
|
for (const vote of round.votes) {
|
|
if (vote.votedFor?.name === a.name) votesA += 1;
|
|
if (vote.votedFor?.name === b.name) votesB += 1;
|
|
if (vote.votedFor?.name === task.model.name) taskVoters.push(vote);
|
|
}
|
|
const isFirst = round.answerTasks[0].model.name === task.model.name;
|
|
const voteCount = isFirst ? votesA : votesB;
|
|
const isWinner = round.phase === "done" && voteCount > (isFirst ? votesB : votesA);
|
|
|
|
const color = getColor(task.model.name);
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.fillRect(x, y, isWinner ? 6 : 4, h);
|
|
|
|
if (isWinner) {
|
|
roundRect(x, y, w, h, 0, "rgba(255,255,255,0.03)");
|
|
}
|
|
|
|
ctx.font = '700 32px "Inter", sans-serif';
|
|
ctx.fillStyle = color;
|
|
const drewCLogo = drawModelLogo(task.model.name, x + 24, y + 16, 32);
|
|
if (drewCLogo) {
|
|
ctx.fillText(task.model.name, x + 64, y + 44);
|
|
} else {
|
|
ctx.fillText(task.model.name, x + 24, y + 44);
|
|
}
|
|
|
|
if (isWinner) {
|
|
ctx.font = '700 18px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#0a0a0a";
|
|
const winW = ctx.measureText("WIN").width;
|
|
roundRect(x + w - 24 - winW - 24, y + 16, winW + 24, 36, 6, "#ededed");
|
|
ctx.fillStyle = "#0a0a0a";
|
|
ctx.fillText("WIN", x + w - 24 - winW - 12, y + 40);
|
|
}
|
|
|
|
const answer =
|
|
!task.finishedAt && !task.result
|
|
? "Writing answer..."
|
|
: task.error
|
|
? task.error
|
|
: task.result ?? "No answer";
|
|
|
|
drawTextBlock(
|
|
task.result ? `"${answer}"` : answer,
|
|
x + 24,
|
|
y + 120,
|
|
w - 48,
|
|
52,
|
|
'400 40px "DM Serif Display", serif',
|
|
isWinner ? "#ededed" : (!task.finishedAt && !task.result ? "#444" : "#888"),
|
|
6,
|
|
);
|
|
|
|
const showVotes = round.phase === "voting" || round.phase === "done";
|
|
if (showVotes) {
|
|
const totalVotes = votesA + votesB;
|
|
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
|
|
|
|
const viewerVoteCount = isFirst ? (round.viewerVotesA ?? 0) : (round.viewerVotesB ?? 0);
|
|
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
|
|
const hasViewerVotes = totalViewerVotes > 0;
|
|
|
|
// Shift model votes up when viewer votes are present
|
|
const modelVoteBarY = hasViewerVotes ? y + h - 110 : y + h - 60;
|
|
const modelVoteTextY = hasViewerVotes ? y + h - 74 : y + h - 24;
|
|
|
|
roundRect(x + 24, modelVoteBarY, w - 48, 4, 2, "#1c1c1c");
|
|
if (pct > 0) {
|
|
roundRect(x + 24, modelVoteBarY, Math.max(8, ((w - 48) * pct) / 100), 4, 2, color);
|
|
}
|
|
|
|
ctx.font = '700 28px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = color;
|
|
ctx.fillText(String(voteCount), x + 24, modelVoteTextY);
|
|
|
|
ctx.font = '600 20px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#444";
|
|
const vTxt = `vote${voteCount === 1 ? "" : "s"}`;
|
|
const vCountW = ctx.measureText(String(voteCount)).width;
|
|
const vTxtW = ctx.measureText(vTxt).width;
|
|
ctx.fillText(vTxt, x + 24 + vCountW + 8, modelVoteTextY - 1);
|
|
|
|
let avatarX = x + 24 + vCountW + 8 + vTxtW + 16;
|
|
const avatarY = modelVoteBarY + 12;
|
|
const avatarSize = 28;
|
|
|
|
for (const v of taskVoters) {
|
|
const vColor = getColor(v.voter.name);
|
|
const drewLogo = drawModelLogo(v.voter.name, avatarX, avatarY, avatarSize);
|
|
|
|
if (!drewLogo) {
|
|
ctx.beginPath();
|
|
ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2);
|
|
ctx.fillStyle = vColor;
|
|
ctx.fill();
|
|
ctx.font = '700 12px "Inter", sans-serif';
|
|
ctx.fillStyle = "#0a0a0a";
|
|
const initial = v.voter.name[0] ?? "?";
|
|
const tw = ctx.measureText(initial).width;
|
|
ctx.fillText(initial, avatarX + avatarSize / 2 - tw / 2, avatarY + avatarSize / 2 + 4);
|
|
}
|
|
|
|
avatarX += avatarSize + 8;
|
|
}
|
|
|
|
// Viewer votes
|
|
if (hasViewerVotes) {
|
|
const viewerPct = Math.round((viewerVoteCount / totalViewerVotes) * 100);
|
|
|
|
roundRect(x + 24, y + h - 56, w - 48, 4, 2, "#1c1c1c");
|
|
if (viewerPct > 0) {
|
|
roundRect(x + 24, y + h - 56, Math.max(8, ((w - 48) * viewerPct) / 100), 4, 2, "#666");
|
|
}
|
|
|
|
ctx.font = '700 22px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#999";
|
|
ctx.fillText(String(viewerVoteCount), x + 24, y + h - 22);
|
|
|
|
const vvCountW = ctx.measureText(String(viewerVoteCount)).width;
|
|
ctx.font = '600 16px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#444";
|
|
const vvTxt = `viewer vote${viewerVoteCount === 1 ? "" : "s"}`;
|
|
ctx.fillText(vvTxt, x + 24 + vvCountW + 8, y + h - 23);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawWaiting() {
|
|
const mainW = WIDTH - 380;
|
|
ctx.font = '400 48px "DM Serif Display", serif';
|
|
ctx.fillStyle = "#888";
|
|
const text = "Waiting for game state...";
|
|
const tw = ctx.measureText(text).width;
|
|
ctx.fillText(text, (mainW - tw) / 2, HEIGHT / 2);
|
|
}
|
|
|
|
function drawDone(scores: Record<string, number>) {
|
|
const mainW = WIDTH - 380;
|
|
const winner = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
|
|
if (!winner) return;
|
|
const [name, points] = winner;
|
|
|
|
ctx.font = '700 20px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = "#444";
|
|
const go = "GAME OVER";
|
|
const gow = ctx.measureText(go).width;
|
|
ctx.fillText(go, (mainW - gow) / 2, HEIGHT / 2 - 100);
|
|
|
|
ctx.font = '400 80px "DM Serif Display", serif';
|
|
ctx.fillStyle = getColor(name);
|
|
const nw = ctx.measureText(name).width;
|
|
ctx.fillText(name, (mainW - nw) / 2, HEIGHT / 2);
|
|
|
|
ctx.font = '600 24px "Inter", sans-serif';
|
|
ctx.fillStyle = "#888";
|
|
const wins = `is the funniest AI`;
|
|
const ww = ctx.measureText(wins).width;
|
|
ctx.fillText(wins, (mainW - ww) / 2, HEIGHT / 2 + 60);
|
|
}
|
|
|
|
function draw() {
|
|
drawHeader();
|
|
if (!state) {
|
|
drawWaiting();
|
|
return;
|
|
}
|
|
|
|
drawScoreboard(state.scores, state.viewerScores ?? {});
|
|
|
|
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
|
|
const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null);
|
|
|
|
if (state.done) {
|
|
drawDone(state.scores);
|
|
} else if (displayRound) {
|
|
drawRound(displayRound);
|
|
} else {
|
|
drawWaiting();
|
|
}
|
|
}
|
|
|
|
function renderLoop() {
|
|
draw();
|
|
window.requestAnimationFrame(renderLoop);
|
|
}
|
|
|
|
function startCanvasCaptureSink() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const sink = params.get("sink");
|
|
if (!sink) return;
|
|
|
|
if (!("MediaRecorder" in window)) {
|
|
setStatus("MediaRecorder unavailable");
|
|
return;
|
|
}
|
|
|
|
const fps = Number.parseInt(params.get("captureFps") ?? "30", 10);
|
|
const bitRate = Number.parseInt(params.get("captureBitrate") ?? "12000000", 10);
|
|
const stream = canvas.captureStream(Number.isFinite(fps) && fps > 0 ? fps : 30);
|
|
const socket = new WebSocket(sink);
|
|
socket.binaryType = "arraybuffer";
|
|
|
|
let recorder: MediaRecorder | null = null;
|
|
const mimeCandidates = [
|
|
"video/webm;codecs=vp8",
|
|
"video/webm;codecs=vp9",
|
|
"video/webm",
|
|
];
|
|
const mimeType =
|
|
mimeCandidates.find((value) => MediaRecorder.isTypeSupported(value)) ?? "";
|
|
|
|
socket.onopen = () => {
|
|
const options: MediaRecorderOptions = {
|
|
videoBitsPerSecond: Number.isFinite(bitRate) && bitRate > 0 ? bitRate : 12_000_000,
|
|
};
|
|
if (mimeType) options.mimeType = mimeType;
|
|
|
|
recorder = new MediaRecorder(stream, options);
|
|
recorder.ondataavailable = async (event) => {
|
|
if (event.data.size === 0) return;
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
if (socket.bufferedAmount > 16_000_000) return;
|
|
const chunk = await event.data.arrayBuffer();
|
|
socket.send(chunk);
|
|
};
|
|
recorder.onerror = () => {
|
|
setStatus("Recorder error");
|
|
};
|
|
recorder.start(250);
|
|
setStatus(`capture->ws ${fps}fps`);
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
recorder?.stop();
|
|
setStatus("capture sink closed");
|
|
};
|
|
}
|
|
|
|
setupWebSocket();
|
|
startCanvasCaptureSink();
|
|
renderLoop();
|