Files
chatroom/resources/js/chat-room/riddle-quiz.js
T

649 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 猜谜活动前端模块
// 监听 RiddleGameStarted / RiddleGameAnswered 事件,提供答题弹窗与刷新恢复能力。
function csrf() {
return document.querySelector('meta[name="csrf-token"]')?.content ?? "";
}
let currentRoundId = 0;
let currentRoomId = 0;
let currentQuizType = "idiom";
const QUIZ_TYPES = ["idiom", "brain_teaser"];
/**
* 兼容新旧字段,提取前端统一使用的猜谜活动回合信息。
*/
export function normalizeQuizRoundPayload(payload) {
const source = payload && typeof payload === "object" ? payload : {};
const quizType = String(source.quiz_type || source.idiom_type || "idiom");
const quizTypeLabel = String(source.quiz_type_label || source.idiom_type_label || (quizType === "idiom" ? "成语题" : "谜题"));
const roundId = Number.parseInt(
String(source.quiz_round_id || source.idiom_game_round_id || source.idom_game_round_id || source.round_id || source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
10,
);
const endedRoundId = Number.parseInt(
String(source.quiz_round_ended_id || source.idiom_game_round_ended_id || "0"),
10,
);
const rewardGold = Number.parseInt(
String(source.quiz_reward_gold ?? source.idiom_reward_gold ?? source.idiom_result_reward_gold ?? source.reward_gold ?? 0),
10,
);
const rewardExp = Number.parseInt(
String(source.quiz_reward_exp ?? source.idiom_reward_exp ?? source.idiom_result_reward_exp ?? source.reward_exp ?? 0),
10,
);
const hint = String(source.quiz_hint || source.hint || source.content || "");
const answer = String(source.quiz_answer || source.idiom_answer || source.answer || "");
return {
quizType,
quizTypeLabel,
roundId: Number.isNaN(roundId) ? 0 : roundId,
endedRoundId: Number.isNaN(endedRoundId) ? 0 : endedRoundId,
rewardGold: Number.isNaN(rewardGold) ? 0 : rewardGold,
rewardExp: Number.isNaN(rewardExp) ? 0 : rewardExp,
hint,
answer,
};
}
/**
* 统一构建“猜谜活动 + 题型”展示标题。
*/
export function buildQuizActivityTitle(payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
return {
activityLabel: "猜谜活动",
typeLabel: quizMeta.quizTypeLabel || "谜题",
quizType: quizMeta.quizType,
};
}
/**
* 判断一条消息是否属于开题消息。
*/
export function isQuizStartMessage(payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
const action = String(payload?.action || "");
return quizMeta.roundId > 0 && quizMeta.endedRoundId <= 0 && !action;
}
/**
* 查找当前回合是否已经有对应的聊天室消息节点。
*/
function findIdiomRoundMessageNode(roundId) {
if (roundId <= 0) {
return null;
}
return document.querySelector(`[data-idiom-round-id="${roundId}"]`);
}
/**
* 刷新后若历史消息里缺少当前进行中的开题卡片,则主动补回一条系统传音消息。
*/
function restoreCurrentQuizMessage(roomId, payload) {
const quizMeta = normalizeQuizRoundPayload(payload);
if (quizMeta.roundId <= 0 || !quizMeta.hint) {
return;
}
if (findIdiomRoundMessageNode(quizMeta.roundId)) {
return;
}
const { activityLabel, typeLabel } = buildQuizActivityTitle(payload);
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-start-restore-${quizMeta.roundId}`,
room_id: roomId,
from_user: "系统传音",
to_user: "大家",
content: `🧩 【${activityLabel}${typeLabel}${quizMeta.hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: quizMeta.roundId,
quiz_hint: quizMeta.hint,
quiz_reward_gold: quizMeta.rewardGold,
quiz_reward_exp: quizMeta.rewardExp,
idiom_game_round_id: quizMeta.roundId,
idiom_reward_gold: quizMeta.rewardGold,
idiom_reward_exp: quizMeta.rewardExp,
sent_at: timeStr,
});
}
/**
* 为指定回合创建统一样式的答题按钮。
*/
function buildIdiomAnswerButton(roundId, hint, rewardGold, rewardExp, typeLabel, quizType = "idiom") {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.idiomAnswerBtn = String(roundId);
btn.dataset.quizAnswerBtn = String(roundId);
btn.dataset.idiomHint = hint;
btn.dataset.quizHint = hint;
btn.dataset.idiomGold = String(rewardGold);
btn.dataset.quizGold = String(rewardGold);
btn.dataset.idiomExp = String(rewardExp);
btn.dataset.quizExp = String(rewardExp);
btn.dataset.quizTypeLabel = typeLabel;
btn.dataset.quizType = quizType;
btn.dataset.quizEnded = "0";
btn.textContent = "🎯 立即答题";
btn.style.cssText =
"padding:4px 12px;background:linear-gradient(135deg,#7c3aed,#a78bfa);" +
"color:#fff;border:none;border-radius:999px;font-size:11px;cursor:pointer;" +
"font-weight:700;line-height:1.2;vertical-align:middle;box-shadow:0 4px 10px rgba(124,58,237,.18);";
return btn;
}
/**
* 查找指定回合的所有答题按钮。
*/
function queryQuizAnswerButtons(roundId = 0) {
const selector = roundId > 0
? `[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`
: "[data-quiz-answer-btn], [data-idiom-answer-btn]";
return Array.from(document.querySelectorAll(selector));
}
/**
* 读取当前页面上该回合已渲染的结算消息,用于历史恢复时补挂答对人名字。
*/
function findQuizWinnerUsername(roundId = 0) {
if (roundId <= 0) {
return "";
}
const resultNode = document.querySelector(`[data-quiz-round-ended-id="${roundId}"]`);
return String(resultNode?.dataset?.quizWinnerUsername || "");
}
/**
* 清理指定回合的所有答题按钮。
*/
export function removeIdiomAnswerButtons(roundId = 0) {
queryQuizAnswerButtons(roundId).forEach((button) => button.remove());
}
/**
* 为结束态按钮补一个答对人标记,避免用户只看到“已结束”不知道是谁抢到了。
*/
function syncQuizWinnerLabel(button, winnerUsername = "") {
if (!(button instanceof HTMLElement)) {
return;
}
const existingLabel = button.parentElement?.querySelector(`[data-quiz-winner-label="${button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || ""}"]`);
if (!winnerUsername) {
existingLabel?.remove();
return;
}
const winnerLabel = existingLabel || document.createElement("span");
winnerLabel.dataset.quizWinnerLabel = String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0");
winnerLabel.textContent = `答对:${winnerUsername}`;
winnerLabel.style.cssText = "margin-left:6px;font-size:11px;line-height:1.2;color:#64748b;font-weight:700;white-space:nowrap;";
if (!existingLabel) {
button.insertAdjacentElement("afterend", winnerLabel);
}
}
/**
* 将指定回合的答题按钮标记为结束态,保留在历史消息中供用户回看。
*/
export function disableIdiomAnswerButtons(roundId = 0, endedText = "本回合已结束", winnerUsername = "") {
queryQuizAnswerButtons(roundId).forEach((button) => {
button.disabled = true;
button.dataset.quizEnded = "1";
button.style.background = "linear-gradient(135deg,#94a3b8,#cbd5e1)";
button.style.color = "#f8fafc";
button.style.cursor = "not-allowed";
button.style.boxShadow = "none";
button.style.opacity = ".92";
button.style.padding = "4px 12px";
button.style.fontSize = "11px";
button.style.lineHeight = "1.2";
button.title = endedText;
button.textContent = "已结束";
syncQuizWinnerLabel(button, winnerUsername);
});
}
/**
* 根据当前回合状态同步按钮可点击性,避免刷新后仍显示过期入口。
*/
function syncQuizAnswerButtons(activeRoundIds) {
const activeIds = new Set((Array.isArray(activeRoundIds) ? activeRoundIds : [activeRoundIds]).filter((roundId) => roundId > 0));
queryQuizAnswerButtons().forEach((button) => {
const buttonRoundId = Number.parseInt(String(button.dataset.quizAnswerBtn || button.dataset.idiomAnswerBtn || "0"), 10);
if (activeIds.has(buttonRoundId)) {
button.disabled = false;
button.dataset.quizEnded = "0";
button.style.background = "linear-gradient(135deg,#7c3aed,#a78bfa)";
button.style.color = "#fff";
button.style.cursor = "pointer";
button.style.boxShadow = "0 4px 10px rgba(124,58,237,.18)";
button.style.opacity = "1";
button.style.padding = "4px 12px";
button.style.fontSize = "11px";
button.style.lineHeight = "1.2";
button.title = "";
button.textContent = "🎯 立即答题";
syncQuizWinnerLabel(button, "");
return;
}
disableIdiomAnswerButtons(buttonRoundId);
});
}
/**
* 把答题按钮挂到对应的消息节点上,而不是盲目追加到最后一条消息。
*/
export function attachIdiomAnswerButton(messageNode, message) {
if (!messageNode || !message) {
return;
}
const quizMeta = normalizeQuizRoundPayload(message);
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (roundId <= 0) {
return;
}
if (quizMeta.endedRoundId > 0) {
return;
}
if (!["星海小博士", "系统传音"].includes(String(message.from_user || ""))) {
return;
}
if (messageNode.querySelector(`[data-quiz-answer-btn="${roundId}"], [data-idiom-answer-btn="${roundId}"]`)) {
return;
}
const button = buildIdiomAnswerButton(roundId, quizMeta.hint, quizMeta.rewardGold, quizMeta.rewardExp, quizMeta.quizTypeLabel, quizMeta.quizType);
const inlineActionAnchor = messageNode.querySelector("[data-quiz-inline-action-anchor]");
if (inlineActionAnchor?.parentNode) {
inlineActionAnchor.parentNode.insertBefore(button, inlineActionAnchor.nextSibling);
} else {
messageNode.appendChild(button);
}
if (quizMeta.endedRoundId > 0) {
disableIdiomAnswerButtons(roundId);
return;
}
const winnerUsername = findQuizWinnerUsername(roundId);
if (winnerUsername) {
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
}
}
/**
* 根据当前服务端回合状态,清理刷新后残留的旧答题按钮。
*/
async function syncCurrentIdiomRound() {
const roomId = Number.parseInt(String(window.chatContext?.roomId || "0"), 10);
if (roomId <= 0) {
return;
}
currentRoomId = roomId;
try {
const responses = await Promise.all(QUIZ_TYPES.map(async (quizType) => {
const response = await fetch(`/riddle-quiz/current?room_id=${roomId}&type=${encodeURIComponent(quizType)}`, {
headers: {
Accept: "application/json",
},
});
return response.json();
}));
responses.forEach((data) => {
if (data?.status === "success" && data?.data) {
restoreCurrentQuizMessage(roomId, data.data);
}
});
const activeRoundIds = responses
.map((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10))
.filter((roundId) => roundId > 0);
currentRoundId = activeRoundIds[0] || 0;
currentQuizType = responses.find((data) => Number.parseInt(String(data?.data?.quiz_round_id || data?.data?.round_id || "0"), 10) === currentRoundId)?.data?.quiz_type || currentQuizType;
syncQuizAnswerButtons(activeRoundIds);
} catch (_error) {
// 当前回合同步失败时不打断聊天主流程,保留现有按钮状态。
}
}
/**
* 收到猜成语出题事件时,在聊天窗口显示提示消息。
*/
function handleRiddleGameStarted(e) {
const quizMeta = normalizeQuizRoundPayload(e.detail);
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
const { roundId, hint, rewardGold, rewardExp } = quizMeta;
const message = String(e.detail?.message || "");
if (!roundId || !hint) return;
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
currentQuizType = quizMeta.quizType || "idiom";
// 线上如果 MessageSent 补消息没有到达,这里主动补一条公屏消息兜底;
// 本地或正常链路下若消息已存在,则只补挂答题按钮,避免重复渲染。
const existingMessageNode = findIdiomRoundMessageNode(roundId);
if (existingMessageNode) {
attachIdiomAnswerButton(existingMessageNode, {
from_user: "星海小博士",
content: message || `🧩 【${activityLabel}${typeLabel}${hint}`,
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: roundId,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
idiom_game_round_id: roundId,
idiom_reward_gold: rewardGold,
idiom_reward_exp: rewardExp,
});
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
return;
}
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-start-live-${roundId}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "系统传音",
to_user: "大家",
content: message || `🧩 【${activityLabel}${typeLabel}${hint}`,
is_secret: false,
font_color: "#7c3aed",
action: "",
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_round_id: roundId,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
idiom_game_round_id: roundId,
idiom_reward_gold: rewardGold,
idiom_reward_exp: rewardExp,
sent_at: timeStr,
});
console.log(`猜谜活动开始:${hint},奖励 ${rewardGold}金/${rewardExp}经验`);
}
/**
* 收到猜成语结果事件。
*/
function handleRiddleGameAnswered(e) {
const quizMeta = normalizeQuizRoundPayload(e.detail);
const { activityLabel, typeLabel } = buildQuizActivityTitle(e.detail);
const answer = quizMeta.answer;
const winnerUsername = String(e.detail?.winner_username || "");
const rewardGold = quizMeta.rewardGold;
const rewardExp = quizMeta.rewardExp;
const roundId = quizMeta.endedRoundId || quizMeta.roundId;
if (!answer) return;
currentRoundId = 0;
currentQuizType = "idiom";
disableIdiomAnswerButtons(roundId, "本回合已结束", winnerUsername);
// 关闭当前用户的答题弹窗(如果开着的话)
const answerModal = document.getElementById("idiom-answer-modal");
if (answerModal && answerModal.style.display !== "none") {
answerModal.style.display = "none";
}
// 实时答题结果与刷新后的历史恢复统一走 appendMessage,避免两套分流逻辑跑偏。
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
window.appendMessage?.({
id: `quiz-result-live-${roundId}-${Date.now()}`,
room_id: currentRoomId || window.chatContext?.roomId || 0,
from_user: "系统传音",
to_user: "大家",
content: `🎉 【${winnerUsername}】率先答对${typeLabel}${answer}」,获得 ${rewardGold} 金币、${rewardExp} 经验!`,
is_secret: false,
font_color: "#16a34a",
action: "idiom_result",
winner_username: winnerUsername,
quiz_type: quizMeta.quizType,
quiz_type_label: typeLabel,
quiz_answer: answer,
quiz_reward_gold: rewardGold,
quiz_reward_exp: rewardExp,
quiz_round_ended_id: roundId,
idiom_answer: answer,
idiom_result_reward_gold: rewardGold,
idiom_result_reward_exp: rewardExp,
idiom_game_round_ended_id: roundId,
sent_at: timeStr,
});
// ── Toast 通知(所有用户都能看到) ──
window.chatToast?.show({
title: `🧩 ${activityLabel} · ${typeLabel}`,
message: `<b>${winnerUsername}</b> 答对了「${answer}」,获得 ${rewardGold}💰 + ${rewardExp}⭐!`,
icon: "🎉",
color: "#16a34a",
duration: 6000,
});
}
/**
* 打开答题弹窗。
*/
function openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel = "成语题", quizType = "idiom") {
currentRoundId = roundId;
currentRoomId = window.chatContext?.roomId || 0;
currentQuizType = quizType || "idiom";
const modal = document.getElementById("idiom-answer-modal");
if (!modal) return;
const hintEl = document.getElementById("idiom-answer-hint");
const rewardEl = document.getElementById("idiom-answer-reward");
const typeEl = document.getElementById("idiom-answer-type");
if (hintEl) hintEl.textContent = hint;
if (rewardEl) rewardEl.textContent = `🎁 答对奖励:${rewardGold} 金币 + ${rewardExp} 经验`;
if (typeEl) typeEl.textContent = typeLabel;
modal.style.display = "flex";
const input = document.getElementById("idiom-answer-input");
if (input) {
input.value = "";
input.focus();
input.disabled = false;
}
const submitBtn = document.getElementById("idiom-answer-submit");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
const feedbackEl = document.getElementById("idiom-answer-feedback");
if (feedbackEl) feedbackEl.textContent = "";
}
/**
* 关闭答题弹窗。
*/
function closeIdiomAnswerModal() {
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.style.display = "none";
}
/**
* 提交答案。
*/
async function submitIdiomAnswer() {
const input = document.getElementById("idiom-answer-input");
const feedbackEl = document.getElementById("idiom-answer-feedback");
const submitBtn = document.getElementById("idiom-answer-submit");
if (!input || !feedbackEl || !submitBtn) return;
const answer = input.value.trim();
if (!answer) {
feedbackEl.textContent = "请输入答案";
feedbackEl.style.color = "#ef4444";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "提交中...";
try {
const response = await fetch("/riddle-quiz/answer", {
method: "POST",
headers: {
"X-CSRF-TOKEN": csrf(),
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({
round_id: currentRoundId,
answer: answer,
room_id: currentRoomId,
quiz_type: currentQuizType,
}),
});
const data = await response.json();
if (data.status === "success") {
feedbackEl.textContent = data.message || "🎉 回答正确!";
feedbackEl.style.color = "#16a34a";
input.disabled = true;
disableIdiomAnswerButtons(
currentRoundId,
"本回合已结束",
String(window.chatContext?.username || ""),
);
// 延迟关闭弹窗
setTimeout(() => {
closeIdiomAnswerModal();
}, 2000);
} else {
feedbackEl.textContent = data.message || "答案不正确";
feedbackEl.style.color = "#ef4444";
if ((data.message || "").includes("已结束") || (data.message || "").includes("抢先答对") || (data.message || "").includes("超时")) {
disableIdiomAnswerButtons(currentRoundId, data.message || "本回合已结束");
}
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
input.focus();
input.select();
}
} catch (error) {
feedbackEl.textContent = "网络错误,请稍后重试";
feedbackEl.style.color = "#ef4444";
submitBtn.disabled = false;
submitBtn.textContent = "提交答案";
}
}
// ── 事件绑定 ──
export function bindIdiomQuizControls() {
// 已经绑定的不再重复绑定
if (document.getElementById("idiom-answer-modal")?.dataset?.idiomBound) return;
const modal = document.getElementById("idiom-answer-modal");
if (modal) modal.dataset.idiomBound = "1";
// 关闭按钮
document.addEventListener("click", (e) => {
const closeBtn = e.target.closest("[data-idiom-answer-close]");
if (closeBtn) {
closeIdiomAnswerModal();
return;
}
// 点击遮罩层关闭
const overlay = e.target.closest("#idiom-answer-modal");
if (overlay && e.target === overlay) {
closeIdiomAnswerModal();
}
});
// 提交按钮
document.addEventListener("click", (e) => {
const submitBtn = e.target.closest("[data-idiom-answer-submit]");
if (submitBtn) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 输入框 Enter 提交
document.addEventListener("keydown", (e) => {
const input = e.target.closest("#idiom-answer-input");
if (input && e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitIdiomAnswer();
}
});
// 聊天消息中的【答题】按钮点击
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-idiom-answer-btn]");
if (!btn) return;
if (btn instanceof HTMLButtonElement && (btn.disabled || btn.dataset.quizEnded === "1")) {
return;
}
const roundId = parseInt(btn.dataset.quizAnswerBtn || btn.dataset.idiomAnswerBtn || "0", 10);
const hint = btn.dataset.quizHint || btn.dataset.idiomHint || "";
const rewardGold = parseInt(btn.dataset.quizGold || btn.dataset.idiomGold || "0", 10);
const rewardExp = parseInt(btn.dataset.quizExp || btn.dataset.idiomExp || "0", 10);
const typeLabel = btn.dataset.quizTypeLabel || "成语题";
currentQuizType = btn.dataset.quizType || (typeLabel === "脑筋急转弯" ? "brain_teaser" : "idiom");
if (roundId > 0) {
openIdiomAnswerModal(roundId, hint, rewardGold, rewardExp, typeLabel, currentQuizType);
}
});
// ── 猜成语结果消息中的用户名可点击 → 打开用户名片
// 注:单击/双击已由 right-panel.js 的全局 [data-chat-message-user] 事件委托统一处理
window.setTimeout(() => {
syncCurrentIdiomRound();
}, 0);
}
// ── 挂载到 window ──
window.openIdiomAnswerModal = openIdiomAnswerModal;
window.closeIdiomAnswerModal = closeIdiomAnswerModal;
window.submitIdiomAnswer = submitIdiomAnswer;
window.handleRiddleGameStarted = handleRiddleGameStarted;
window.handleRiddleGameAnswered = handleRiddleGameAnswered;