feat: add unified contest pool and realtime game modes

This commit is contained in:
2026-08-10 00:31:21 +08:00
parent 97ca20413e
commit 40e9462842
18 changed files with 822 additions and 152 deletions
+177 -31
View File
@@ -7,6 +7,11 @@
opponentProgress: 0,
reconnectTimer: null,
};
const GAME_LABELS = {
quiz: "口算竞速",
sudoku: "数独 Timerun",
twenty_four: "24 点竞速",
};
function stopTimers() {
if (realtime.pollTimer) window.clearInterval(realtime.pollTimer);
@@ -90,7 +95,7 @@
match.opponent?.status &&
match.opponent.status !== "active"
) {
realtime.opponentProgress = match.attempt?.questions.length || 0;
realtime.opponentProgress = matchProgressTotal(match);
updateProgressUI();
updateConnectionStatus("对手已提交,完成后将立即结算");
}
@@ -130,7 +135,7 @@
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · REALTIME`;
`${match.match_type === "challenge" ? "联机码约战" : "随机匹配"} · ${GAME_LABELS[match.game_kind] || "实时竞技"}`;
const title = document.createElement("h2");
title.textContent = match.contest;
const status = document.createElement("div");
@@ -182,7 +187,7 @@
message.textContent =
match.match_type === "challenge"
? "把联机码发给朋友。对方加入后会自动开始,无需再次点击。"
: "正在寻找同赛道、相近 Rating 的玩家。";
: `正在寻找${GAME_LABELS[match.game_kind] || "同玩法"}对手。`;
panel.append(pulse, message);
if (match.challenge_code) {
const code = document.createElement("button");
@@ -217,30 +222,41 @@
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 = `<span>你</span><b id="self-progress">0 / ${match.attempt.questions.length}</b>`;
self.innerHTML = `<span>你</span><b id="self-progress">0 / ${total}</b>`;
const opponent = document.createElement("div");
opponent.innerHTML =
`<span>${match.opponent?.nickname || "对手"}</span>` +
`<b id="opponent-progress">${realtime.opponentProgress} / ${match.attempt.questions.length}</b>`;
`<b id="opponent-progress">${realtime.opponentProgress} / ${total}</b>`;
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} / ${realtime.match.attempt.questions.length}`;
self.textContent = `${selfCount} / ${total}`;
}
}
const opponent = document.querySelector("#opponent-progress");
if (opponent && realtime.match?.attempt) {
opponent.textContent =
`${realtime.opponentProgress} / ${realtime.match.attempt.questions.length}`;
opponent.textContent = `${realtime.opponentProgress} / ${total}`;
}
}
@@ -252,6 +268,109 @@
}
}
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;
@@ -264,6 +383,16 @@
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) => {
@@ -328,7 +457,15 @@
: "平局";
const score = document.createElement("p");
score.textContent =
`${match.attempt.score} 分 · ${match.opponent.score}${match.opponent.nickname}`;
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;
@@ -336,7 +473,11 @@
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") {
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";
@@ -356,22 +497,24 @@
}
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);
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() {
@@ -391,15 +534,18 @@
}
function currentRealtimeContest() {
return state.contests.find(
(contest) => contest.kind === "realtime" && contest.track === state.track
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: {},
body: { game_kind: state.matchMode },
});
openMatch(match);
}
@@ -408,13 +554,13 @@
if (!requireAuth()) return;
const contest = currentRealtimeContest();
if (!contest) {
showToast("当前赛道没有可用的实时比赛");
showToast("当前没有可用的实时比赛");
return;
}
try {
const match = await api(`contests/${contest.slug}/challenges/`, {
method: "POST",
body: {},
body: { game_kind: state.matchMode },
});
openMatch(match);
} catch (error) {