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) => ``).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) => `
${escapeHtml(item.name)}${item.id === state.selfId ? "(你)" : ""} ${item.score} 分
${item.eliminated ? "已淘汰" : `${item.cardCount} 张牌`} ${item.connected ? "在线" : "离线"}
`).join(""); const hand = self?.hand ?? []; $("#hand-total").textContent = `${hand.length} 张`; $("#hand").innerHTML = hand.length ? hand.map((value) => `
${value}
`).join("") : `${state.phase === "waiting" ? "游戏开始后发牌" : "没有手牌"}`; $("#logs").innerHTML = [...state.logs].reverse().map((log) => `
  • ${escapeHtml(log.message)}
  • `).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 = `

    ${state.players.length === 3 ? "由房主开始游戏" : "分享房间码,邀请另外两名玩家"}

    `; return; } if (state.phase === "finished") { area.innerHTML = `

    隐藏牌是 ${state.hidden?.join("、")}

    `; return; } if (self.eliminated) { area.innerHTML = `

    你已被淘汰,可以继续观看牌局。

    `; return; } if (state.pending?.type === "request") { renderRequestResponse(area); return; } if (state.pending?.type === "guess") { renderGuessResponse(area); return; } if (!myTurn) { area.innerHTML = `

    观察各玩家手牌数量的变化,等待你的回合。

    `; return; } const targets = state.players.filter((item) => item.id !== state.selfId && !item.eliminated); area.innerHTML = `
    ${actionMode === "guess" ? guessForm() : `
    `} `; 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 = `

    等待 ${escapeHtml(player(pending.targetId)?.name ?? "目标玩家")} 回应要牌请求。

    `; return; } area.innerHTML = `

    ${escapeHtml(player(pending.requesterId)?.name)} 请求数字 ${pending.values.join(" 或 ")} 的${pending.mode === "one" ? "一张牌" : "其中一种全部牌"}。

    ${pending.values.map((value) => ``).join("")}
    `; 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) => `
    ${escapeHtml(player(guess.playerId)?.name)}:${guess.values.join("、")} ${expectedPlayerId === state.selfId && guess.playerId !== state.selfId ? `` : ""}
    `).join(""); if (expectedPlayerId !== state.selfId) { const waitingFor = player(expectedPlayerId)?.name ?? "其他玩家"; area.innerHTML = `
    ${guessList}

    等待 ${escapeHtml(waitingFor)} 回应。

    `; return; } const forced = pending.forcedPlayerId === state.selfId; area.innerHTML = `
    ${guessList}
    ${forced ? `

    你是本轮唯一放弃者,必须选择认同一项猜测或提出自己的猜测。

    ` : ""}
    ${forced ? "" : ``}
    `; 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 `
    `; } 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]); }