438 lines
20 KiB
JavaScript
438 lines
20 KiB
JavaScript
/* Meyer – client. SSE state sync + actions. */
|
||
"use strict";
|
||
|
||
/* ---------- rank/combo mirror of the server ---------- */
|
||
const COMBOS = [
|
||
{ a: 1, b: 2, label: "Meyer", sub: "1 + 2" },
|
||
{ a: 1, b: 3, label: "Lille-Meyer", sub: "1 + 3" },
|
||
{ a: 6, b: 6, label: "Par 6", sub: "6 + 6" },
|
||
{ a: 5, b: 5, label: "Par 5", sub: "5 + 5" },
|
||
{ a: 4, b: 4, label: "Par 4", sub: "4 + 4" },
|
||
{ a: 3, b: 3, label: "Par 3", sub: "3 + 3" },
|
||
{ a: 2, b: 2, label: "Par 2", sub: "2 + 2" },
|
||
{ a: 1, b: 1, label: "Par 1", sub: "1 + 1" },
|
||
{ a: 6, b: 5, label: "6 + 5", sub: "" },
|
||
{ a: 6, b: 4, label: "6 + 4", sub: "" },
|
||
];
|
||
const DYN = [[6,3],[6,2],[5,4],[5,3],[5,2],[4,3],[4,2],[3,2]];
|
||
function rankOf(a, b) {
|
||
const x = Math.min(a, b), y = Math.max(a, b);
|
||
for (let i = 0; i < COMBOS.length; i++) if (COMBOS[i].a === y && COMBOS[i].b === x) return i;
|
||
const i = DYN.findIndex(c => c[0] === y && c[1] === x);
|
||
return i >= 0 ? 10 + i : -1;
|
||
}
|
||
function comboOf(rank) {
|
||
if (rank >= 0 && rank < COMBOS.length) return COMBOS[rank];
|
||
const i = rank - 10;
|
||
if (i >= 0 && i < DYN.length) { const [a, b] = DYN[i]; return { a, b, label: `${a} + ${b}`, sub: "" }; }
|
||
return { a: 6, b: 6, label: "6 + 6", sub: "" };
|
||
}
|
||
function comboName(c) {
|
||
if (c.sub === "1 + 2") return "Meyer";
|
||
if (c.sub === "1 + 3") return "Lille-Meyer";
|
||
return c.label;
|
||
}
|
||
function eyeLabel(n) {
|
||
if (n <= 0) return "Ude 💀";
|
||
return n + (n === 1 ? " øje" : " øjne");
|
||
}
|
||
function pipLayout(n) {
|
||
const M = { 1: [6], 2: [1, 3], 3: [1, 6, 3], 4: [1, 3, 7, 9], 5: [1, 3, 7, 9, 6], 6: [1, 3, 7, 9, 4, 8] };
|
||
return (M[n] || []).map(p => `<span class="pip p${p}"></span>`).join("");
|
||
}
|
||
|
||
/* ---------- state ---------- */
|
||
const S = {
|
||
name: localStorage.getItem("meyer.name") || "",
|
||
uid: null, roomId: null, snap: null,
|
||
es: null, view: "name",
|
||
flippedL: false, flippedR: false,
|
||
showDice: false, rolling: false,
|
||
};
|
||
const $ = (s) => document.querySelector(s);
|
||
const $$ = (s) => [...document.querySelectorAll(s)];
|
||
const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
let flashMsg = null, flashUntil = 0;
|
||
function toast(msg) { flashMsg = msg; flashUntil = Date.now() + 3500; }
|
||
|
||
async function api(action, extra = {}) {
|
||
const res = await fetch("/api", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action, uid: S.uid, name: S.name, ...extra }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.error || "Fejl");
|
||
return data;
|
||
}
|
||
function act(action, extra) { api(action, extra).catch(e => toast(e.message)); }
|
||
|
||
function setView(v) {
|
||
S.view = v;
|
||
for (const id of ["view-name", "view-lobby", "view-game"])
|
||
$("#" + id).classList.toggle("hidden", id !== "view-" + v);
|
||
}
|
||
|
||
/* ---------- name entry ---------- */
|
||
const nameInput = $("#name-input");
|
||
nameInput.value = S.name;
|
||
nameInput.addEventListener("input", () => { $("#btn-enter").disabled = nameInput.value.trim().length < 1; });
|
||
nameInput.addEventListener("keydown", e => { if (e.key === "Enter" && !$("#btn-enter").disabled) enterLobby(); });
|
||
$("#btn-enter").addEventListener("click", enterLobby);
|
||
$("#btn-change-name").addEventListener("click", () => {
|
||
const n = prompt("Dit navn:", S.name);
|
||
if (n && n.trim()) { S.name = n.trim().slice(0, 24); localStorage.setItem("meyer.name", S.name); $("#me-name").textContent = S.name; }
|
||
});
|
||
$("#btn-rules").addEventListener("click", () => $("#modal-rules").classList.remove("hidden"));
|
||
$("#btn-close-rules").addEventListener("click", () => $("#modal-rules").classList.add("hidden"));
|
||
$("#modal-rules").addEventListener("click", e => { if (e.target.id === "modal-rules") e.target.classList.add("hidden"); });
|
||
function enterLobby() {
|
||
S.name = nameInput.value.trim().slice(0, 24);
|
||
if (!S.name) return;
|
||
localStorage.setItem("meyer.name", S.name);
|
||
$("#me-name").textContent = S.name;
|
||
setView("lobby");
|
||
refreshRooms();
|
||
}
|
||
|
||
/* ---------- lobby ---------- */
|
||
function refreshRooms() {
|
||
fetch("/api/rooms").then(r => r.json()).then(d => renderRoomList(d.rooms)).catch(() => {});
|
||
}
|
||
$("#btn-refresh").addEventListener("click", refreshRooms);
|
||
$("#btn-create").addEventListener("click", async () => {
|
||
try {
|
||
const d = await api("create", { roomName: $("#new-room-name").value.trim(), addBot: true });
|
||
S.uid = d.uid; S.roomId = d.room; $("#new-room-name").value = "";
|
||
connectToRoom(d.snap);
|
||
} catch (e) { alert(e.message); }
|
||
});
|
||
function renderRoomList(rooms) {
|
||
const el = $("#room-list");
|
||
if (!rooms.length) { el.innerHTML = `<div class="room-empty">Ingen borde lige nu – opret det første! 🍺</div>`; return; }
|
||
el.innerHTML = rooms.map(r => {
|
||
const names = r.players.map(p => esc(p.name) + (p.isBot ? " 🤖" : "")).join(", ");
|
||
const full = r.players.length >= r.maxPlayers;
|
||
const chip = full ? `<span class="chip full">Fuld</span>` :
|
||
r.phase === "waiting" ? `<span class="chip waiting">Ventende</span>` : `<span class="chip playing">Spiller</span>`;
|
||
const join = full ? `<span class="chip">🔒</span>` : `<button class="btn primary sm" data-join="${r.id}">Sæt dig</button>`;
|
||
return `<div class="room ${r.phase !== 'waiting' ? 'playing' : ''}">
|
||
<div class="room-info">
|
||
<div class="room-name">🍻 ${esc(r.name)} <span style="opacity:.4;font-weight:400">#${r.id}</span></div>
|
||
<div class="room-players">${names || "<i>tomt</i>"}</div>
|
||
</div>
|
||
<div class="room-state">${chip}${join}</div>
|
||
</div>`;
|
||
}).join("");
|
||
$$("#room-list [data-join]").forEach(b => b.addEventListener("click", async () => {
|
||
try {
|
||
const d = await api("join", { roomId: b.dataset.join });
|
||
S.uid = d.uid; S.roomId = d.room;
|
||
connectToRoom(d.snap);
|
||
} catch (e) { alert(e.message); refreshRooms(); }
|
||
}));
|
||
}
|
||
setInterval(() => { if (S.view === "lobby") refreshRooms(); }, 5000);
|
||
|
||
/* ---------- room connection (SSE) ---------- */
|
||
function connectToRoom(snap) {
|
||
if (S.es) { S.es.close(); S.es = null; }
|
||
setView("game");
|
||
$("#chat-log").innerHTML = "";
|
||
S.flippedL = S.flippedR = false; S.showDice = false; S.rolling = false;
|
||
S.snap = snap; S.roomId = snap.id;
|
||
render();
|
||
const es = new EventSource(`/api/events?uid=${S.uid}`);
|
||
S.es = es;
|
||
es.onmessage = (ev) => {
|
||
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||
if (m.type === "state") { S.snap = m.snap; }
|
||
else if (m.type === "chat") {
|
||
const line = document.createElement("div");
|
||
line.className = "log-line";
|
||
line.innerHTML = `<span class="who">${esc(m.from)}:</span> ${esc(m.text)}`;
|
||
$("#chat-log").appendChild(line);
|
||
$("#chat-log").scrollTop = $("#chat-log").scrollHeight;
|
||
}
|
||
};
|
||
render();
|
||
}
|
||
$("#btn-leave").addEventListener("click", async () => {
|
||
try { if (S.uid) await api("leave"); } catch (e) {}
|
||
if (S.es) { S.es.close(); S.es = null; }
|
||
S.snap = null; S.uid = null; S.roomId = null;
|
||
setView("lobby"); refreshRooms();
|
||
});
|
||
|
||
/* ---------- render (idempotent, runs on a timer) ---------- */
|
||
let prevKey = "";
|
||
function me() { return S.snap ? S.snap.players.find(p => p.uid === S.uid) : null; }
|
||
function myTurn() { return S.snap && S.snap.turnUid === S.uid && S.snap.phase !== "waiting"; }
|
||
function actorUid() {
|
||
const s = S.snap; if (!s) return null;
|
||
if (s.phase === "challenging" && s.pending) return s.pending.challenger;
|
||
return s.turnUid;
|
||
}
|
||
function iAmActor() { return actorUid() !== null && actorUid() === S.uid; }
|
||
|
||
function render() {
|
||
if (S.view !== "game" || !S.snap) return;
|
||
const s = S.snap;
|
||
const iAmHost = s.hostUid === S.uid;
|
||
const mp = me();
|
||
|
||
$("#game-room-name").textContent = s.name;
|
||
$("#my-name-game").textContent = mp ? mp.name : S.name;
|
||
$("#my-turn-chip").classList.toggle("hidden", !iAmActor());
|
||
|
||
// phase chip
|
||
const ph = $("#game-phase");
|
||
ph.textContent =
|
||
s.phase === "waiting" ? (s.gameOver ? "🏆 Spillet er slut" : "⏳ Afventer start") :
|
||
s.phase === "acting" ? "🎲 Terningerne ruller" :
|
||
s.phase === "peeking" ? "👀 Under øllerne" : "🫙 Øllerne skal løftes";
|
||
|
||
// turn banner
|
||
const tb = $("#turn-banner");
|
||
if (s.phase === "waiting") {
|
||
tb.className = "turn-banner them";
|
||
tb.innerHTML = iAmHost
|
||
? `🍻 <b>${s.players.length}</b> ved bordet. Start spillet, når I alle er klar.`
|
||
: `⏳ I venter på at bordet startes…`;
|
||
} else if (iAmActor()) {
|
||
tb.className = "turn-banner mine";
|
||
tb.innerHTML = {
|
||
acting: `🍻 <b>Din tur!</b> Rul terningerne under dine øller…`,
|
||
peeking: `🍻 <b>Din tur!</b> Kig under øllerne, så sig hvad du har (eller bluf)…`,
|
||
challenging: `🫙 <b>${esc(s.turnName)}</b> sagde <b>${esc(s.lastClaim ? s.lastClaim.label : "…")}</b> – løft deres øller, eller tro på dem!`,
|
||
}[s.phase] || "…";
|
||
} else {
|
||
tb.className = "turn-banner them";
|
||
tb.innerHTML = {
|
||
acting: `⏳ <b>${esc(s.turnName)}</b> ruller under øllerne…`,
|
||
peeking: `👀 <b>${esc(s.turnName)}</b> kigger under øllerne…`,
|
||
challenging: `🫙 <b>${esc(byName(s.pending.challenger))}</b> skal nu løfte øllerne og tjekke ${esc(s.turnName)}!`,
|
||
}[s.phase] || "…";
|
||
}
|
||
|
||
// bar (persistent claim)
|
||
const lc = $("#last-claim");
|
||
if (s.lastClaim && s.phase !== "waiting") {
|
||
lc.classList.remove("hidden");
|
||
lc.innerHTML = `📏 <b>${esc(s.lastClaim.byName)}</b> satte stangen til <b>${esc(s.lastClaim.label)}</b> – næste slag skal være højere${s.lastClaim.by === S.uid ? " (det var dig!)" : ""}`;
|
||
} else lc.classList.add("hidden");
|
||
|
||
// opponents
|
||
const opp = s.players.filter(p => p.uid !== S.uid);
|
||
$("#opp-table").innerHTML = opp.length ? opp.map(p => `
|
||
<div class="opp-seat ${s.turnUid === p.uid && s.phase !== 'waiting' ? 'active' : ''} ${p.eliminated ? 'dead-seat' : ''}">
|
||
<div class="seat-name"><span class="dot ${p.connected ? 'on' : 'off'}"></span>${esc(p.name)}${p.isBot ? " 🤖" : ""}</div>
|
||
<div class="mini-cups"><div class="mini-cup"></div><div class="mini-cup"></div></div>
|
||
<div class="opp-eye ${p.eliminated ? 'dead' : ''}">${eyeLabel(p.score)}</div>
|
||
</div>`).join("") : `<div class="dice-msg">Alene ved bordet…</div>`;
|
||
|
||
// scoreboard
|
||
$("#score-list").innerHTML = [...s.players].sort((a, b) => b.score - a.score).map(p => `
|
||
<div class="score-row ${s.turnUid === p.uid && s.phase !== 'waiting' ? 'turn' : ''} ${p.eliminated ? 'dead' : ''}">
|
||
<span class="score-name"><span class="dot ${p.connected ? 'on' : 'off'}"></span>${esc(p.name)}${p.isBot ? " 🤖" : ""}${p.uid === S.uid ? " (dig)" : ""}</span>
|
||
<span class="score-eye">${eyeLabel(p.score)}</span>
|
||
</div>`).join("");
|
||
|
||
// host actions
|
||
const ha = $("#host-actions");
|
||
if (iAmHost) {
|
||
if (s.phase === "waiting" && !s.gameOver) ha.innerHTML = `<button class="btn primary sm" id="btn-start">▶️ Start spillet</button>`;
|
||
else if (s.phase === "waiting" && s.gameOver) ha.innerHTML = `<button class="btn primary sm" id="btn-restart">🔄 Spil igen</button>`;
|
||
else ha.innerHTML = `<button class="btn ghost sm" id="btn-restart">🔄 Nyt spil (nulstil øjne)</button>`;
|
||
const bs = $("#btn-start"); if (bs) bs.addEventListener("click", () => act("start"));
|
||
const br = $("#btn-restart"); if (br) br.addEventListener("click", () => { if (confirm("Starte et nyt spil? Alle øjne nulstilles.")) act("restart"); });
|
||
} else if (s.phase === "waiting") {
|
||
const host = s.players.find(p => p.uid === s.hostUid);
|
||
ha.innerHTML = `<div class="note">👑 ${esc(host ? host.name : "…")} skal starte spillet.</div>`;
|
||
} else ha.innerHTML = "";
|
||
|
||
// game log
|
||
syncGameLog(s);
|
||
|
||
// my board
|
||
renderMyBoard(s);
|
||
|
||
// overlay
|
||
renderOverlay(s, iAmHost);
|
||
|
||
// flash toast
|
||
if (flashMsg && flashUntil > Date.now()) {
|
||
let f = $("#flash");
|
||
if (!f) { f = document.createElement("div"); f.id = "flash"; document.body.appendChild(f); }
|
||
f.style.cssText = "position:fixed;top:16px;left:50%;transform:translateX(-50%);background:#c94f33;color:#fff;padding:10px 20px;border-radius:12px;z-index:99;font-weight:600;box-shadow:0 8px 20px rgba(0,0,0,.5)";
|
||
f.textContent = flashMsg;
|
||
} else if (flashMsg) { const f = $("#flash"); if (f) f.remove(); flashMsg = null; }
|
||
}
|
||
function byName(uid) { const p = S.snap && S.snap.players.find(x => x.uid === uid); return p ? p.name : "?"; }
|
||
|
||
function syncGameLog(s) {
|
||
const log = $("#chat-log");
|
||
const existing = log.querySelectorAll(".log-line.sys");
|
||
if (existing.length === s.log.length) return; // unchanged
|
||
// full re-render of system lines (chat lines preserved)
|
||
[...log.querySelectorAll(".log-line.sys")].forEach(n => n.remove());
|
||
const frag = document.createDocumentFragment();
|
||
for (const e of s.log) {
|
||
const line = document.createElement("div");
|
||
let cls = "log-line sys";
|
||
if (e.t.includes("💀")) cls = "log-line bad";
|
||
else if (e.t.includes("🏆")) cls = "log-line good";
|
||
else if (e.t.includes("🎲 Øllerne")) cls = "log-line good";
|
||
line.className = cls;
|
||
const t = new Date(e.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||
line.innerHTML = `<span class="ts">${t}</span> ${esc(e.t)}`;
|
||
frag.appendChild(line);
|
||
}
|
||
log.appendChild(frag);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function renderMyBoard(s) {
|
||
const a = $("#my-actions");
|
||
const diceEl = $("#my-dice");
|
||
const cupsL = $("#cup-l"), cupsR = $("#cup-r");
|
||
|
||
// cups
|
||
const canPeek = myTurn() && s.phase === "peeking";
|
||
cupsL.classList.toggle("flipped", canPeek && S.flippedL);
|
||
cupsR.classList.toggle("flipped", canPeek && S.flippedR);
|
||
|
||
// dice area
|
||
if (S.rolling) {
|
||
diceEl.innerHTML = `<div class="die rolling"></div><div class="die rolling"></div>`;
|
||
return;
|
||
}
|
||
if (myTurn() && s.phase === "peeking" && S.showDice && s.myDice) {
|
||
diceEl.innerHTML = `<div class="die">${pipLayout(s.myDice[0])}</div><div class="die">${pipLayout(s.myDice[1])}</div>`;
|
||
} else if (myTurn() && s.phase === "acting") {
|
||
diceEl.innerHTML = `<div class="dice-msg">Dine øller er klar. Rul når du er parat. 🍺</div>`;
|
||
} else if (myTurn() && s.phase === "peeking") {
|
||
diceEl.innerHTML = `<div class="dice-msg">Terningerne er under dine øller – løft dem efter tur 👀</div>`;
|
||
} else if (s.hasDice && s.phase !== "waiting") {
|
||
diceEl.innerHTML = `<div class="dice-msg">🫙 ${esc(s.turnName)} har øllerne mellem hænderne…</div>`;
|
||
} else {
|
||
diceEl.innerHTML = `<div class="dice-msg">—</div>`;
|
||
}
|
||
|
||
// actions
|
||
a.innerHTML = "";
|
||
if (s.phase === "waiting") return;
|
||
if (iAmActor()) {
|
||
if (s.phase === "acting") {
|
||
a.innerHTML = `<div class="row"><button class="btn primary big" id="btn-roll">🎲 Rul terningerne</button></div>`;
|
||
$("#btn-roll").addEventListener("click", () => { S.rolling = true; act("roll"); setTimeout(() => { S.rolling = false; render(); }, 1000); });
|
||
} else if (s.phase === "peeking") {
|
||
const truth = (S.showDice && s.myDice) ? rankOf(s.myDice[0], s.myDice[1]) : -1;
|
||
const minR = s.lastClaim ? s.lastClaim.rank + 1 : 0;
|
||
let grid = "";
|
||
for (let r = Math.max(minR, 0); r <= 17; r++) {
|
||
const c = comboOf(r);
|
||
const hint = r === truth ? "⭐ dit virkelige slag" : "🎭 bluff";
|
||
grid += `<button class="combo-btn" data-rank="${r}" title="${hint}">
|
||
<span class="rank-label">${esc(comboName(c))}</span>${c.sub ? ` <span class="rank-sub">(${c.sub})</span>` : ""}</button>`;
|
||
}
|
||
a.innerHTML = `
|
||
<div class="actions-title">🗣️ Sig hvad du har – eller bluf og sig højere:</div>
|
||
<div class="combo-grid">${grid}</div>
|
||
<div class="row" style="margin-top:10px">
|
||
<button class="btn ghost" id="btn-reroll" title="Slaget er for lavt: rul igen og send videre uden at kigge">🔁 Rul igen</button>
|
||
<button class="btn ghost" id="btn-fwd" title="Send øllerne videre uden at sige noget">📤 Send videre</button>
|
||
</div>`;
|
||
$$("#my-actions .combo-btn").forEach(b => b.addEventListener("click", () => { S.showDice = false; S.flippedL = S.flippedR = false; act("declare", { rank: +b.dataset.rank }); }));
|
||
$("#btn-reroll").addEventListener("click", () => { S.showDice = false; S.flippedL = S.flippedR = false; act("reroll"); });
|
||
$("#btn-fwd").addEventListener("click", () => { S.showDice = false; S.flippedL = S.flippedR = false; act("sendForward"); });
|
||
} else if (s.phase === "challenging" && s.pending && s.pending.challenger === S.uid) {
|
||
a.innerHTML = `
|
||
<div class="actions-title">🫙 ${esc(s.turnName)} sagde <b>${esc(s.lastClaim ? s.lastClaim.label : "…")}</b>. Tror du på dem?</div>
|
||
<div class="row">
|
||
<button class="btn danger big" id="btn-ch">🫙 Løft øllerne!</button>
|
||
<button class="btn primary big" id="btn-pass">👍 Tro på dem</button>
|
||
</div>`;
|
||
$("#btn-ch").addEventListener("click", () => act("challenge"));
|
||
$("#btn-pass").addEventListener("click", () => act("pass"));
|
||
}
|
||
} else {
|
||
a.innerHTML = `<div class="dice-msg">Hænder i skød – ${esc(s.turnName)} er i gang…</div>`;
|
||
}
|
||
}
|
||
|
||
function renderOverlay(s, iAmHost) {
|
||
const ov = $("#overlay");
|
||
if (s.gameOver) {
|
||
ov.classList.remove("hidden");
|
||
const alive = s.players.filter(p => !p.eliminated).sort((a, b) => b.score - a.score);
|
||
const winner = alive.length ? alive[0] : null;
|
||
$("#overlay-emoji").textContent = "🏆";
|
||
$("#overlay-title").textContent = winner ? `${winner.name} vandt!` : "Spillet er slut!";
|
||
const stand = [...s.players].sort((a, b) => b.score - a.score)
|
||
.map((p, i) => `${i + 1}. ${esc(p.name)} – ${p.eliminated ? "ude 💀" : p.score + " øjne"}`).join(" · ");
|
||
$("#overlay-text").innerHTML = `${stand}<br><span style="opacity:.7;font-size:13px">Sidste mand ved bordet vinder.</span>`;
|
||
$("#overlay-actions").innerHTML = iAmHost
|
||
? `<button class="btn primary" id="ov-restart">🔄 Spil igen</button>`
|
||
: `<button class="btn ghost" id="ov-close2">👀 Luk</button>`;
|
||
const rs = $("#ov-restart"); if (rs) rs.addEventListener("click", () => act("restart"));
|
||
const cl2 = $("#ov-close2"); if (cl2) cl2.addEventListener("click", () => ov.classList.add("hidden"));
|
||
return;
|
||
}
|
||
const started = s.players.some(p => p.score < 6);
|
||
const anyAlive = s.players.filter(p => !p.eliminated);
|
||
if (s.phase === "waiting" && !started && anyAlive.length >= 1) {
|
||
ov.classList.remove("hidden");
|
||
$("#overlay-emoji").textContent = "🍻";
|
||
$("#overlay-title").textContent = s.name;
|
||
const names = s.players.map(p => p.name).join(", ");
|
||
const canStart = iAmHost && s.players.length >= 2;
|
||
$("#overlay-text").innerHTML =
|
||
`Ved bordet: <b>${esc(names)}</b><br>Max. ${s.maxPlayers} spillere. ${canStart ? "" : "<br>" + (iAmHost ? "Husk mindst 2 spillere – opret flere borde eller spørg nogen om at sætte sig." : "Vent på at værten starter…")}`;
|
||
$("#overlay-actions").innerHTML = canStart
|
||
? `<button class="btn primary" id="ov-start">▶️ Start spillet</button>`
|
||
: `<button class="btn ghost" id="ov-close">👀 Luk</button>`;
|
||
const st = $("#ov-start"); if (st) st.addEventListener("click", () => act("start"));
|
||
const cl = $("#ov-close"); if (cl) cl.addEventListener("click", () => ov.classList.add("hidden"));
|
||
return;
|
||
}
|
||
ov.classList.add("hidden");
|
||
}
|
||
|
||
/* ---------- cup interactions ---------- */
|
||
function tryPeek(which) {
|
||
const s = S.snap;
|
||
if (!s || !myTurn() || s.phase !== "peeking") return;
|
||
if (which === "l") S.flippedL = !S.flippedL; else S.flippedR = !S.flippedR;
|
||
if (S.flippedL && S.flippedR) {
|
||
S.showDice = true;
|
||
act("peek", { revealed: true });
|
||
}
|
||
render();
|
||
}
|
||
$("#cup-l").addEventListener("click", () => {
|
||
if (S.snap && myTurn() && S.snap.phase === "peeking") tryPeek("l");
|
||
else { $("#cup-l").classList.add("nope"); setTimeout(() => $("#cup-l").classList.remove("nope"), 350); }
|
||
});
|
||
$("#cup-r").addEventListener("click", () => {
|
||
if (S.snap && myTurn() && S.snap.phase === "peeking") tryPeek("r");
|
||
else { $("#cup-r").classList.add("nope"); setTimeout(() => $("#cup-r").classList.remove("nope"), 350); }
|
||
});
|
||
|
||
/* ---------- chat ---------- */
|
||
function sendChat() {
|
||
const inp = $("#chat-input");
|
||
const t = inp.value.trim();
|
||
if (!t) return;
|
||
inp.value = "";
|
||
act("chat", { text: t });
|
||
}
|
||
$("#btn-chat").addEventListener("click", sendChat);
|
||
$("#chat-input").addEventListener("keydown", e => { if (e.key === "Enter") sendChat(); });
|
||
|
||
/* ---------- main loop ---------- */
|
||
setInterval(() => { if (S.view === "game" && S.snap) render(); }, 350);
|
||
setInterval(() => { if (flashMsg && flashUntil < Date.now()) { flashMsg = null; const f = $("#flash"); if (f) f.remove(); } }, 800);
|
||
|
||
/* boot */
|
||
if (S.name) { nameInput.value = S.name; $("#btn-enter").disabled = false; }
|