(function () { const realtime = { match: null, socket: null, pollTimer: null, clockTimer: null, opponentProgress: 0, reconnectTimer: null, }; function stopTimers() { if (realtime.pollTimer) window.clearInterval(realtime.pollTimer); if (realtime.clockTimer) window.clearInterval(realtime.clockTimer); realtime.pollTimer = null; realtime.clockTimer = null; } function closeSocket() { if (realtime.reconnectTimer) window.clearTimeout(realtime.reconnectTimer); realtime.reconnectTimer = null; if (realtime.socket) { realtime.socket.onclose = null; realtime.socket.close(); } realtime.socket = null; } function websocketUrl(path) { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}${path}`; } function connectSocket() { closeSocket(); if (!realtime.match?.websocket_path) return; const socket = new WebSocket(websocketUrl(realtime.match.websocket_path)); realtime.socket = socket; socket.addEventListener("open", () => { updateConnectionStatus("实时连接已建立"); socket.send(JSON.stringify({ type: "ping" })); }); socket.addEventListener("message", async (event) => { let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === "state") { await refreshMatch(); } else if ( message.type === "progress" && message.user_id !== String(state.user?.id) ) { realtime.opponentProgress = message.answered_count; updateProgressUI(); } }); socket.addEventListener("close", () => { updateConnectionStatus("实时连接中断,正在使用轮询"); if (["waiting", "active"].includes(realtime.match?.status)) { realtime.reconnectTimer = window.setTimeout(connectSocket, 2000); } }); socket.addEventListener("error", () => { updateConnectionStatus("WebSocket 不可用,轮询仍在工作"); }); } function updateConnectionStatus(message) { const node = document.querySelector("#realtime-connection"); if (node) node.textContent = message; } async function refreshMatch() { if (!realtime.match?.match_id) return; try { const match = await api(`contests/matches/${realtime.match.match_id}/`); const previous = realtime.match; const previousStatus = previous.status; realtime.match = match; const shouldRender = previous.status !== match.status || previous.attempt?.status !== match.attempt?.status; if (shouldRender) renderMatch(); else { updateClock(); if ( previous.opponent?.status !== match.opponent?.status && match.opponent?.status && match.opponent.status !== "active" ) { realtime.opponentProgress = match.attempt?.questions.length || 0; updateProgressUI(); updateConnectionStatus("对手已提交,完成后将立即结算"); } } if (previousStatus === "waiting" && match.status === "active") { showToast(`已匹配到 ${match.opponent.nickname}`); } if (!["waiting", "active"].includes(match.status)) { stopTimers(); closeSocket(); } } catch (error) { if (error.status === 404) { stopTimers(); closeSocket(); } } } function startPolling() { stopTimers(); realtime.pollTimer = window.setInterval(refreshMatch, 2000); realtime.clockTimer = window.setInterval(updateClock, 250); } function openMatch(match) { realtime.match = match; realtime.opponentProgress = 0; renderMatch(); const dialog = document.querySelector("#experience-dialog"); if (!dialog.open) dialog.showModal(); connectSocket(); if (["waiting", "active"].includes(match.status)) startPolling(); } function header(root, match) { const label = document.createElement("span"); label.className = "kicker"; label.textContent = `${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`; const title = document.createElement("h2"); title.textContent = match.contest; const status = document.createElement("div"); status.className = "realtime-status-line"; const connection = document.createElement("span"); connection.id = "realtime-connection"; connection.textContent = "正在建立实时连接…"; const clock = document.createElement("strong"); clock.id = "realtime-clock"; status.append(connection, clock); root.append(label, title, status); } function updateClock() { const clock = document.querySelector("#realtime-clock"); if (!clock || !realtime.match) return; if (realtime.match.status === "waiting") { const expiresAt = new Date(realtime.match.expires_at).getTime(); const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); clock.textContent = `联机码 ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} 后失效`; return; } if (realtime.match.status === "active") { const startedAt = new Date(realtime.match.started_at).getTime(); const elapsed = (Date.now() - startedAt) / 1000; const remaining = Math.max(0, Math.ceil(realtime.match.duration_seconds - elapsed)); clock.textContent = `剩余 ${remaining} 秒`; if (remaining === 0) refreshMatch(); return; } clock.textContent = ""; } async function copyCode(code) { try { await navigator.clipboard.writeText(code); showToast(`联机码 ${code} 已复制`); } catch { showToast(`联机码:${code}`); } } function renderWaiting(root, match) { const panel = document.createElement("div"); panel.className = "realtime-waiting"; const pulse = document.createElement("span"); pulse.className = "realtime-pulse"; const message = document.createElement("p"); message.textContent = match.match_type === "challenge" ? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。" : "正在寻找同赛道、相近 Rating 的玩家。"; panel.append(pulse, message); if (match.challenge_code) { const code = document.createElement("button"); code.className = "challenge-code"; code.textContent = match.challenge_code; code.title = "点击复制联机码"; code.addEventListener("click", () => copyCode(match.challenge_code)); panel.append(code); } if (match.is_owner) { const cancel = document.createElement("button"); cancel.className = "ghost-button"; cancel.textContent = "取消等待"; cancel.addEventListener("click", async () => { cancel.disabled = true; try { realtime.match = await api(`contests/matches/${match.match_id}/cancel/`, { method: "POST", body: {}, }); renderMatch(); stopTimers(); closeSocket(); } catch (error) { showToast(error.message); cancel.disabled = false; } }); panel.append(cancel); } root.append(panel); updateClock(); } function progressPanel(root, match) { const panel = document.createElement("div"); panel.className = "realtime-progress-panel"; const self = document.createElement("div"); self.innerHTML = `0 / ${match.attempt.questions.length}`; const opponent = document.createElement("div"); opponent.innerHTML = `${match.opponent?.nickname || "对手"}` + `${realtime.opponentProgress} / ${match.attempt.questions.length}`; panel.append(self, opponent); root.append(panel); } function updateProgressUI(selfCount) { if (Number.isInteger(selfCount)) { const self = document.querySelector("#self-progress"); if (self && realtime.match?.attempt) { self.textContent = `${selfCount} / ${realtime.match.attempt.questions.length}`; } } const opponent = document.querySelector("#opponent-progress"); if (opponent && realtime.match?.attempt) { opponent.textContent = `${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`; } } function sendProgress(answeredCount) { if (realtime.socket?.readyState === WebSocket.OPEN) { realtime.socket.send( JSON.stringify({ type: "progress", answered_count: answeredCount }) ); } } function renderActive(root, match) { progressPanel(root, match); const attempt = match.attempt; if (attempt.status !== "active") { const waiting = document.createElement("div"); waiting.className = "realtime-submitted"; waiting.innerHTML = "答案已锁定

等待对手提交。双方完成后才会公开答案和 Rating 变化。

"; root.append(waiting); updateClock(); return; } const form = document.createElement("form"); form.className = "choice-list realtime-answer-form"; attempt.questions.forEach((question) => { const field = document.createElement("label"); field.textContent = `${question.order}. ${question.prompt}`; const input = document.createElement("input"); input.name = String(question.order); input.inputMode = "decimal"; input.autocomplete = "off"; input.addEventListener("input", () => { const answered = [...form.querySelectorAll("input")].filter( (item) => item.value.trim() ).length; updateProgressUI(answered); sendProgress(answered); }); field.append(input); form.append(field); }); const submit = document.createElement("button"); submit.className = "primary-button"; submit.type = "submit"; submit.textContent = "提交并等待对手"; form.append(submit); form.addEventListener("submit", async (event) => { event.preventDefault(); submit.disabled = true; const data = new FormData(form); try { realtime.match = await api( `contests/attempts/${attempt.attempt_id}/submit/`, { method: "POST", headers: { "Idempotency-Key": createIdempotencyKey() }, body: { answers: attempt.questions.map((question) => ({ order: question.order, answer: data.get(String(question.order)) || "", })), }, }, ); renderMatch(); } catch (error) { showToast(error.message); submit.disabled = false; } }); root.append(form); updateClock(); } function renderCompleted(root, match) { const result = document.createElement("section"); result.className = `realtime-result result-${match.result.winner}`; const outcome = document.createElement("strong"); outcome.textContent = match.result.winner === "self" ? "获胜" : match.result.winner === "opponent" ? "本局惜败" : "平局"; const score = document.createElement("p"); score.textContent = `你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}`; const timeLine = document.createElement("p"); timeLine.className = "realtime-time-line"; const selfMs = match.attempt.duration_ms || 0; const opponentMs = match.opponent.duration_ms || 0; const selfSec = (selfMs / 1000).toFixed(1); const opponentSec = (opponentMs / 1000).toFixed(1); timeLine.textContent = `你 ${selfSec}s · ${match.opponent.nickname} ${opponentSec}s`; if (match.attempt.score === match.opponent.score && match.result.winner !== "draw") { const faster = selfMs < opponentMs ? "你" : match.opponent.nickname; const tiebreak = document.createElement("small"); tiebreak.className = "realtime-tiebreak"; tiebreak.textContent = `同分,${faster}更快完成,快者胜`; timeLine.append(tiebreak); } const rating = document.createElement("b"); const sign = match.result.rating_delta > 0 ? "+" : ""; rating.textContent = `Rating ${sign}${match.result.rating_delta} → ${match.result.rating_after}`; result.append(outcome, score, timeLine, rating); root.append(result); // 刷新右上角用户 Rating 显示 if (typeof loadUser === "function") loadUser(); const review = document.createElement("div"); review.className = "realtime-review"; match.attempt.questions.forEach((question) => { const item = document.createElement("article"); const title = document.createElement("b"); title.textContent = `${question.order}. ${question.prompt}`; const answer = document.createElement("p"); answer.textContent = `你的答案:${question.submitted_answer || "未作答"} · 正确答案:${question.correct_answer}`; const explanation = document.createElement("small"); explanation.textContent = question.explanation || ""; item.className = question.is_correct ? "correct" : "incorrect"; item.append(title, answer, explanation); review.append(item); }); root.append(review); } function renderMatch() { const root = document.querySelector("#experience-content"); const match = realtime.match; root.replaceChildren(); header(root, match); if (match.status === "waiting") renderWaiting(root, match); else if (match.status === "active") renderActive(root, match); else if (match.status === "completed") renderCompleted(root, match); else { const message = document.createElement("p"); message.className = "scene"; message.textContent = "这场匹配已取消或联机码已过期。"; root.append(message); } } function currentRealtimeContest() { return state.contests.find( (contest) => contest.kind === "realtime" && contest.track === state.track ); } async function startRandom(contest) { const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: {}, }); openMatch(match); } async function createChallenge() { if (!requireAuth()) return; const contest = currentRealtimeContest(); if (!contest) { showToast("当前赛道没有可用的实时比赛"); return; } try { const match = await api(`contests/${contest.slug}/challenges/`, { method: "POST", body: {}, }); openMatch(match); } catch (error) { showToast(error.message); } } async function joinChallenge(event) { event.preventDefault(); if (!requireAuth()) return; const input = document.querySelector("#challenge-code-input"); const challengeCode = input.value.trim().toUpperCase(); if (challengeCode.length !== 6) { showToast("请输入 6 位联机码"); return; } try { const match = await api("contests/challenges/join/", { method: "POST", body: { challenge_code: challengeCode }, }); input.value = ""; openMatch(match); } catch (error) { showToast(error.message); } } function init() { document .querySelector("#challenge-create") .addEventListener("click", createChallenge); document .querySelector("#challenge-join-form") .addEventListener("submit", joinChallenge); document .querySelector("#challenge-code-input") .addEventListener("input", (event) => { event.target.value = event.target.value .toUpperCase() .replace(/[^ABCDEFGHJKLMNPQRSTUVWXYZ23456789]/g, "") .slice(0, 6); }); } function reset() { stopTimers(); closeSocket(); realtime.match = null; realtime.opponentProgress = 0; } window.HuluRealtime = { init, startRandom, refreshMatch, reset }; })();