Files
2026-08-16 20:50:31 +02:00

438 lines
20 KiB
JavaScript
Raw Permalink 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.
/* 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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; }