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
+21 -9
View File
@@ -29,7 +29,7 @@ const state = {
user: null,
stories: [],
contests: [],
track: "standard",
matchMode: "quiz",
videoCatalog: null,
videos: [],
videoAbility: "",
@@ -446,12 +446,21 @@ async function loadContests() {
function renderContests() {
const root = $("#contest-list");
const contests = state.contests.filter((contest) => contest.track === state.track);
const contests = ["realtime", "daily", "practice"]
.map((kind) => {
const candidates = state.contests.filter((contest) => contest.kind === kind);
return (
candidates.find((contest) => contest.track === "open") ||
candidates.find((contest) => contest.track === "standard") ||
candidates[0]
);
})
.filter(Boolean);
root.classList.remove("loading");
root.replaceChildren(...contests.map((contest) => card({
meta: contest.kind === "realtime" ? "实时竞技" : contest.kind === "daily" ? "今日挑战" : "自由练习",
title: contest.title,
body: contest.kind === "realtime" ? "Rating 匹配,同题序列,服务端计时和唯一结算。" : "完成整组题目,正式答案只在提交后显示。",
title: contest.title.replace(/^(入门|标准|进阶)/, ""),
body: contest.kind === "realtime" ? "统一玩家池,支持口算、数独与 24 点,服务端计时和 Rating 结算。" : "每次随机抽取题目,正式答案只在提交后显示。",
foot: `${contest.duration_seconds}`,
action: contest.kind === "realtime" ? "开始匹配 →" : "开始答题 →",
onClick: () => beginContest(contest),
@@ -827,7 +836,7 @@ async function loadProfile() {
profile.recent_games.forEach((game) => {
const item = document.createElement("div");
const name = document.createElement("b");
name.textContent = `${game.label} · ${game.difficulty}`;
name.textContent = game.label;
const detail = document.createElement("span");
detail.textContent =
game.status === "completed"
@@ -1255,10 +1264,13 @@ function bindUI() {
$("#register-form").classList.toggle("hidden", button.dataset.authTab !== "register");
$("#auth-error").textContent = "";
}));
$$("#track-switch button").forEach((button) => button.addEventListener("click", () => {
state.track = button.dataset.track;
$$("#track-switch button").forEach((item) => item.classList.toggle("active", item === button));
renderContests();
$$("#match-mode-switch button").forEach((button) => button.addEventListener("click", () => {
state.matchMode = button.dataset.matchMode;
$$("#match-mode-switch button").forEach((item) => {
item.classList.toggle("active", item === button);
});
const label = button.querySelector("b").textContent;
$("#challenge-create").textContent = `创建${label}约战`;
}));
$("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
+5 -24
View File
@@ -14,23 +14,6 @@
},
};
function difficultySelect() {
const select = document.createElement("select");
select.className = "game-difficulty";
[
["easy", "入门"],
["standard", "标准"],
["hard", "进阶"],
].forEach(([value, label]) => {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
if (value === "standard") option.selected = true;
select.append(option);
});
return select;
}
function gameCard(game) {
const meta = GAME_META[game.kind];
const article = document.createElement("article");
@@ -50,12 +33,11 @@
summary.textContent = game.summary;
const controls = document.createElement("div");
controls.className = "game-card-controls";
const difficulty = difficultySelect();
const start = document.createElement("button");
start.className = "primary-button";
start.textContent = meta.action;
start.addEventListener("click", () => startGame(game.kind, difficulty.value));
controls.append(difficulty, start);
start.addEventListener("click", () => startGame(game.kind));
controls.append(start);
article.append(top, kicker, title, summary, controls);
return article;
}
@@ -70,12 +52,12 @@
}
}
async function startGame(kind, difficulty) {
async function startGame(kind) {
if (!requireAuth()) return;
try {
const attempt = await api(`contests/games/${kind}/start/`, {
method: "POST",
body: { difficulty },
body: {},
});
renderGame(attempt);
$game("#experience-dialog").showModal();
@@ -88,8 +70,7 @@
const fragment = document.createDocumentFragment();
const label = document.createElement("span");
label.className = "kicker";
label.textContent =
`${GAME_META[attempt.kind].kicker} · ${attempt.difficulty.toUpperCase()}`;
label.textContent = GAME_META[attempt.kind].kicker;
const title = document.createElement("h2");
title.textContent = GAME_META[attempt.kind].title;
fragment.append(label, title);
+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) {