Files
MateriaMedica/public/app.js
2026-06-11 22:13:55 +08:00

261 lines
10 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.
const socket = io();
const $ = (selector) => document.querySelector(selector);
const sessionKey = "materia-medica-session";
const cardValues = [2, 3, 4, 5];
let state = null;
let actionMode = "one";
let toastTimer;
const showToast = (message) => {
const toast = $("#toast");
toast.textContent = message;
toast.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove("show"), 2600);
};
const emit = (event, payload = {}) => socket.emit(event, payload);
const options = (values = cardValues) => values.map((value) => `<option value="${value}">${value}</option>`).join("");
const player = (id) => state?.players.find((item) => item.id === id);
socket.on("connect", () => {
$("#connection").textContent = "已连接";
$("#connection").classList.add("online");
const saved = JSON.parse(localStorage.getItem(sessionKey) || "null");
if (saved) emit("session:resume", saved);
});
socket.on("disconnect", () => {
$("#connection").textContent = "正在重连";
$("#connection").classList.remove("online");
});
socket.on("session", (session) => {
localStorage.setItem(sessionKey, JSON.stringify(session));
});
socket.on("room:state", (room) => {
state = room;
$("#entry").classList.add("hidden");
$("#game").classList.remove("hidden");
render();
});
socket.on("error:message", showToast);
socket.on("private:message", showToast);
$("#create").addEventListener("click", () => emit("room:create", { name: $("#name").value }));
$("#join").addEventListener("click", () =>
emit("room:join", { name: $("#name").value, roomId: $("#room-code").value.trim().toUpperCase() })
);
$("#start").addEventListener("click", () => emit("game:start"));
$("#copy-room").addEventListener("click", async () => {
await navigator.clipboard?.writeText(state.id);
showToast("房间码已复制");
});
function render() {
const self = player(state.selfId);
const current = player(state.currentPlayerId);
$("#copy-room").textContent = state.id;
$("#start").classList.toggle("hidden", state.phase !== "waiting" || state.hostId !== state.selfId);
const titles = {
waiting: state.players.length < 3 ? `等待玩家 ${state.players.length}/3` : "三人到齐,可以开始",
playing: current ? `${current.name} 的回合` : "牌局进行中",
finished: state.winnerId ? `${player(state.winnerId)?.name ?? "玩家"} 获胜` : "游戏结束"
};
$("#status-title").textContent = titles[state.phase];
$("#players").innerHTML = state.players.map((item) => `
<article class="player ${item.id === state.selfId ? "self" : ""} ${item.id === state.currentPlayerId ? "current" : ""} ${item.eliminated ? "eliminated" : ""}">
<div class="player-head">
<span class="player-name">${escapeHtml(item.name)}${item.id === state.selfId ? "(你)" : ""}</span>
<span class="player-score">${item.score} 分</span>
</div>
<div class="player-meta">
<span>${item.eliminated ? "已淘汰" : `${item.cardCount} 张牌`}</span>
<span><i class="dot ${item.connected ? "online" : ""}"></i> ${item.connected ? "在线" : "离线"}</span>
</div>
</article>
`).join("");
const hand = self?.hand ?? [];
$("#hand-total").textContent = `${hand.length}`;
$("#hand").innerHTML = hand.length
? hand.map((value) => `<div class="card" aria-label="数字 ${value}">${value}</div>`).join("")
: `<span class="empty">${state.phase === "waiting" ? "游戏开始后发牌" : "没有手牌"}</span>`;
$("#logs").innerHTML = [...state.logs].reverse().map((log) => `
<li>${escapeHtml(log.message)}<time>${new Date(log.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time></li>
`).join("");
renderActions();
}
function renderActions() {
const area = $("#actions");
const self = player(state.selfId);
const myTurn = state.currentPlayerId === state.selfId;
$("#turn-note").textContent = state.phase === "playing" ? (myTurn ? "轮到你行动" : "等待其他玩家") : "";
if (state.phase === "waiting") {
area.innerHTML = `<p class="empty">${state.players.length === 3 ? "由房主开始游戏" : "分享房间码,邀请另外两名玩家"}</p>`;
return;
}
if (state.phase === "finished") {
area.innerHTML = `<div class="response-box"><p class="response-copy">隐藏牌是 <strong>${state.hidden?.join("、")}</strong>。</p></div>`;
return;
}
if (self.eliminated) {
area.innerHTML = `<p class="empty">你已被淘汰,可以继续观看牌局。</p>`;
return;
}
if (state.pending?.type === "request") {
renderRequestResponse(area);
return;
}
if (state.pending?.type === "guess") {
renderGuessResponse(area);
return;
}
if (!myTurn) {
area.innerHTML = `<p class="empty">观察各玩家手牌数量的变化,等待你的回合。</p>`;
return;
}
const targets = state.players.filter((item) => item.id !== state.selfId && !item.eliminated);
area.innerHTML = `
<div class="action-tabs">
<button data-mode="one" class="${actionMode === "one" ? "active" : ""}">取一张</button>
<button data-mode="all" class="${actionMode === "all" ? "active" : ""}">取同号全部</button>
<button data-mode="guess" class="${actionMode === "guess" ? "active" : ""}">猜隐藏牌</button>
</div>
${actionMode === "guess" ? guessForm() : `
<form id="request-form" class="action-form">
<label>目标<select name="targetId">${targets.map((item) => `<option value="${item.id}">${escapeHtml(item.name)}</option>`).join("")}</select></label>
<label>牌号一<select name="first">${options()}</select></label>
<label>牌号二<select name="second">${options()}</select></label>
<button class="primary">发出请求</button>
</form>
`}
`;
area.querySelectorAll("[data-mode]").forEach((button) => {
button.addEventListener("click", () => {
actionMode = button.dataset.mode;
renderActions();
});
});
$("#request-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
emit("game:request", {
targetId: form.get("targetId"),
values: [Number(form.get("first")), Number(form.get("second"))],
mode: actionMode
});
});
$("#guess-form")?.addEventListener("submit", sendGuess);
}
function renderRequestResponse(area) {
const pending = state.pending;
if (pending.targetId !== state.selfId) {
area.innerHTML = `<p class="empty">等待 ${escapeHtml(player(pending.targetId)?.name ?? "目标玩家")} 回应要牌请求。</p>`;
return;
}
area.innerHTML = `
<div class="response-box">
<p class="response-copy">${escapeHtml(player(pending.requesterId)?.name)} 请求数字 <strong>${pending.values.join(" 或 ")}</strong> 的${pending.mode === "one" ? "一张牌" : "其中一种全部牌"}。</p>
<div class="response-actions">
${pending.values.map((value) => `<button class="primary" data-give="${value}">交出数字 ${value}</button>`).join("")}
<button class="danger" data-decline>不给</button>
</div>
</div>
`;
area.querySelectorAll("[data-give]").forEach((button) =>
button.addEventListener("click", () => emit("game:request-answer", { value: Number(button.dataset.give) }))
);
area.querySelector("[data-decline]").addEventListener("click", () => emit("game:request-answer", { value: null }));
}
function renderGuessResponse(area) {
const pending = state.pending;
const expectedPlayerId = pending.forcedPlayerId ?? pending.responderIds[pending.responseIndex];
const guessList = pending.guesses.map((guess) => `
<div class="guess-option">
<span><strong>${escapeHtml(player(guess.playerId)?.name)}</strong>${guess.values.join("、")}</span>
${expectedPlayerId === state.selfId && guess.playerId !== state.selfId
? `<button class="primary" data-agree="${guess.playerId}">认同</button>`
: ""}
</div>
`).join("");
if (expectedPlayerId !== state.selfId) {
const waitingFor = player(expectedPlayerId)?.name ?? "其他玩家";
area.innerHTML = `
<div class="response-box">
<div class="guess-list">${guessList}</div>
<p class="empty">等待 ${escapeHtml(waitingFor)} 回应。</p>
</div>
`;
return;
}
const forced = pending.forcedPlayerId === state.selfId;
area.innerHTML = `
<div class="response-box">
<div class="guess-list">${guessList}</div>
${forced ? `<p class="response-copy">你是本轮唯一放弃者,必须选择认同一项猜测或提出自己的猜测。</p>` : ""}
<div class="response-actions">
<button data-counter>另猜</button>
${forced ? "" : `<button class="danger" data-choice="abandon">放弃</button>`}
</div>
<form id="counter-form" class="guess-pair hidden">
<select name="first">${options()}</select>
<select name="second">${options()}</select>
<button class="primary">提交新猜测</button>
</form>
</div>
`;
area.querySelectorAll("[data-agree]").forEach((button) =>
button.addEventListener("click", () =>
emit("game:guess-answer", { choice: "agree", guesserId: button.dataset.agree })
)
);
area.querySelectorAll("[data-choice]").forEach((button) =>
button.addEventListener("click", () => emit("game:guess-answer", { choice: button.dataset.choice }))
);
area.querySelector("[data-counter]").addEventListener("click", () => $("#counter-form").classList.remove("hidden"));
$("#counter-form").addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
emit("game:guess-answer", {
choice: "counter",
values: [Number(form.get("first")), Number(form.get("second"))]
});
});
}
function guessForm() {
return `
<form id="guess-form" class="action-form">
<label>第一张<select name="first">${options()}</select></label>
<label>第二张<select name="second">${options()}</select></label>
<button class="primary">提出猜测</button>
</form>
`;
}
function sendGuess(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
emit("game:guess", { values: [Number(form.get("first")), Number(form.get("second"))] });
}
function escapeHtml(value = "") {
return String(value).replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;"
})[character]);
}