feat: add unified contest pool and realtime game modes
This commit is contained in:
+177
-31
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user