(function () { const realtime = { match: null, socket: null, pollTimer: null, clockTimer: null, opponentProgress: 0, reconnectTimer: null, }; const GAME_LABELS = { quiz: "口算竞速", sudoku: "数独 Timerun", twenty_four: "24 点竞速", }; 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 = matchProgressTotal(match); 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" ? "联机码约战" : "随机匹配"} · ${GAME_LABELS[match.game_kind] || "实时竞技"}`; 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" ? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。" : `正在寻找${GAME_LABELS[match.game_kind] || "同玩法"}对手。`; 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 matchProgressTotal(match) { return ( match.game_kind === "sudoku" ? 81 : match.game_kind === "twenty_four" ? 1 : match.attempt?.questions.length || 0 ); } function progressPanel(root, match) { const total = matchProgressTotal(match); const panel = document.createElement("div"); panel.className = "realtime-progress-panel"; const self = document.createElement("div"); self.innerHTML = `你0 / ${total}`; const opponent = document.createElement("div"); opponent.innerHTML = `${match.opponent?.nickname || "对手"}` + `${realtime.opponentProgress} / ${total}`; panel.append(self, opponent); root.append(panel); } function updateProgressUI(selfCount) { const total = matchProgressTotal(realtime.match); if (Number.isInteger(selfCount)) { const self = document.querySelector("#self-progress"); if (self && realtime.match?.attempt) { self.textContent = `${selfCount} / ${total}`; } } const opponent = document.querySelector("#opponent-progress"); if (opponent && realtime.match?.attempt) { opponent.textContent = `${realtime.opponentProgress} / ${total}`; } } function sendProgress(answeredCount) { if (realtime.socket?.readyState === WebSocket.OPEN) { realtime.socket.send( JSON.stringify({ type: "progress", answered_count: answeredCount }) ); } } async function submitRealtimeGame(attempt, payload) { realtime.match = await api( `contests/games/attempts/${attempt.attempt_id}/submit/`, { method: "POST", headers: { "Idempotency-Key": createIdempotencyKey() }, body: payload, }, ); renderMatch(); } function renderTwentyFourGame(root, match) { const attempt = match.attempt; const numbers = document.createElement("div"); numbers.className = "twenty-four-numbers"; attempt.puzzle.numbers.forEach((number) => { const tile = document.createElement("span"); tile.textContent = number; numbers.append(tile); }); const form = document.createElement("form"); form.className = "twenty-four-form"; const input = document.createElement("input"); input.className = "formula-input"; input.placeholder = "四个数字各用一次,例如:6/(1-3/4)"; input.autocomplete = "off"; input.addEventListener("input", () => { sendProgress(input.value.trim() ? 1 : 0); updateProgressUI(input.value.trim() ? 1 : 0); }); const error = document.createElement("p"); error.className = "form-error"; const submit = document.createElement("button"); submit.className = "primary-button"; submit.type = "submit"; submit.textContent = "验证并锁定答案"; form.append(input, error, submit); form.addEventListener("submit", async (event) => { event.preventDefault(); submit.disabled = true; error.textContent = ""; try { await submitRealtimeGame(attempt, { expression: input.value }); } catch (requestError) { error.textContent = requestError.message; submit.disabled = false; } }); root.append(numbers, form); } function renderSudokuGame(root, match) { const attempt = match.attempt; const board = document.createElement("div"); board.className = "sudoku-board"; attempt.puzzle.grid.forEach((rowValues, row) => { rowValues.forEach((givenValue, column) => { const input = document.createElement("input"); input.inputMode = "numeric"; input.pattern = "[1-9]"; input.maxLength = 1; input.dataset.row = row; input.dataset.column = column; input.value = givenValue || ""; input.readOnly = Boolean(givenValue); input.className = givenValue ? "given" : ""; input.setAttribute("aria-label", `第 ${row + 1} 行第 ${column + 1} 列`); input.addEventListener("input", () => { input.value = input.value.replace(/[^1-9]/g, "").slice(0, 1); const count = [...board.querySelectorAll("input")].filter( (item) => item.value ).length; sendProgress(count); updateProgressUI(count); }); board.append(input); }); }); const error = document.createElement("p"); error.className = "form-error"; const submit = document.createElement("button"); submit.className = "primary-button"; submit.type = "button"; submit.textContent = "检查并锁定数独"; submit.addEventListener("click", async () => { submit.disabled = true; error.textContent = ""; const grid = Array.from({ length: 9 }, () => Array(9).fill(0)); board.querySelectorAll("input").forEach((input) => { grid[Number(input.dataset.row)][Number(input.dataset.column)] = Number(input.value || 0); }); try { await submitRealtimeGame(attempt, { grid }); } catch (requestError) { error.textContent = requestError.message; submit.disabled = false; } }); root.append(board, error, submit); } 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; } if (match.game_kind === "twenty_four") { renderTwentyFourGame(root, match); updateClock(); return; } if (match.game_kind === "sudoku") { renderSudokuGame(root, match); 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.game_kind === "quiz" ? `你 ${match.attempt.score} 分 · ${match.opponent.score} 分 ${match.opponent.nickname}` : `${GAME_LABELS[match.game_kind]} · ${ match.attempt.status === "completed" ? "你已完成" : "你未完成" } · ${ match.opponent.status === "completed" ? `${match.opponent.nickname} 已完成` : `${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.result.winner !== "draw" && (match.game_kind !== "quiz" || match.attempt.score === match.opponent.score) ) { 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); if (state.user) { state.user.rating = match.result.rating_after; updateUserUI(); } loadUser(); if (match.game_kind === "quiz") { 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() { const contests = state.contests.filter((contest) => contest.kind === "realtime"); return ( contests.find((contest) => contest.track === "open") || contests.find((contest) => contest.track === "standard") || contests[0] ); } async function startRandom(contest) { const match = await api(`contests/${contest.slug}/matchmaking/`, { method: "POST", body: { game_kind: state.matchMode }, }); 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: { game_kind: state.matchMode }, }); 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 }; })();