// The UNO table, gone multiplayer. // // Same bargain as hold'em: the browser holds no game. You sit down with a stack, // and every hand each seat antes into a pot the winner takes. One move goes up and // what comes back is that move *and every bot turn it handed off to*, as a script // of events this file plays back slowly enough to follow. Other people are at the // felt, so a stream keeps your view live while they play; a nudge on it means the // table changed, and you refetch your own seat's redacted view. // // Two rules carried over, both load-bearing: the browser never learns another // seat's hand (it gets a count), and the chip bar only moves when chips actually // cross the border β€” at sit-down and get-up. A pot won mid-session moves inside // the engine and never touches your purse; it moves your *stack*, which is the // number on the rail. (function () { "use strict"; var root = document.querySelector("[data-uno]"); if (!root) return; var FX = window.PeteFX; var G = window.PeteGames; var seatsEl = root.querySelector("[data-seats]"); var handEl = root.querySelector("[data-hand]"); var deckEl = root.querySelector("[data-deck]"); var deckCntEl = root.querySelector("[data-deck-count]"); var discardEl = root.querySelector("[data-discard]"); var colourEl = root.querySelector("[data-colour]"); var turnEl = root.querySelector("[data-turn-label]"); var countEl = root.querySelector("[data-count-label]"); var verdictEl = root.querySelector("[data-verdict]"); var potEl = root.querySelector("[data-pot]"); var stackEl = root.querySelector("[data-stack]"); var tableEl = root.querySelector("[data-table-name]"); var feltEl = root.querySelector(".pete-uno"); var wildEl = root.querySelector("[data-wild]"); var gateEl = root.querySelector("[data-unogate]"); var callBtn = root.querySelector("[data-uno-call]"); var timerEl = root.querySelector("[data-uno-timer]"); var billEl = root.querySelector("[data-bill]"); var actingEl = root.querySelector("[data-acting]"); var betweenEl = root.querySelector("[data-between]"); var sittingEl = root.querySelector("[data-sitting]"); var chatEl = root.querySelector("[data-chat]"); var drawBtn = root.querySelector("[data-draw]"); var passBtn = root.querySelector("[data-pass]"); var takeBtn = root.querySelector("[data-take]"); var takeN = root.querySelector("[data-take-n]"); var hintEl = root.querySelector("[data-hint]"); var dealBtn = root.querySelector("[data-deal]"); var leaveBtn = root.querySelector("[data-leave]"); var sitBtn = root.querySelector("[data-sit]"); var buySlider = root.querySelector("[data-buyin-slider]"); var buyLabel = root.querySelector("[data-buyin]"); var buyNote = root.querySelector("[data-buyin-note]"); var tableStackEl = root.querySelector("[data-table-stack]"); var boughtInEl = root.querySelector("[data-bought-in]"); var anteNoteEl = root.querySelector("[data-ante-note]"); var gameMsgEl = root.querySelector("[data-game-msg]"); var betweenMsg = root.querySelector("[data-between-msg]"); var tableMsg = root.querySelector("[data-table-msg]"); var chatLog = root.querySelector("[data-chat-log]"); var chatSeen = {}; // chat line ids already on the rail, to drop echoed duplicates var chatForm = root.querySelector("[data-chat-form]"); var chatInput = root.querySelector("[data-chat-input]"); var lobbyEl = root.querySelector("[data-lobby]"); var lobbyWrap = root.querySelector("[data-lobby-wrap]"); var busy = false; var game = null; // the table as the server last described it (a v.uno) var me = 0; // your seat at the table var tier = null; // the selected tier button, when sitting var pendingWild = -1; var played = -1; var stream = null; var pendingSync = false; var CALL_MS = 2600; var gate = null; var gateTimer = 0; var reduced = FX.reduced; function pace(ms) { return reduced ? 0 : ms; } function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } function say(text, tone, where) { var el = where || tableMsg; if (!el) return; if (!text) { el.classList.add("hidden"); return; } el.textContent = text; el.classList.remove("hidden"); el.style.color = tone === "bad" ? "#cc3d4a" : ""; } function fmt(n) { return (n || 0).toLocaleString(); } // ---- drawing the cards ----------------------------------------------------- var FACES = { "skip": { mid: "🚫", corner: "🚫" }, "reverse": { mid: "⇄", corner: "⇄" }, "+2": { mid: "+2", corner: "+2" }, "wild": { mid: "β˜…", corner: "β˜…" }, "+4": { mid: "+4", corner: "+4" }, "skip all": { mid: "SKIP ALL", corner: "⊘⊘", size: "words" }, "discard all": { mid: "DISCARD ALL", corner: "≑", size: "words" }, "rev +4": { mid: "⇄+4", corner: "⇄4", size: "mid" }, "+6": { mid: "+6", corner: "+6" }, "+10": { mid: "+10", corner: "+10", size: "mid" }, "roulette": { mid: "?", corner: "?" }, }; function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; } var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true }; function card(c, opts) { opts = opts || {}; var f = faceOf(c); var el = document.createElement(opts.button ? "button" : "div"); if (opts.button) el.type = "button"; el.className = "pete-uno-card"; el.dataset.c = c.wild ? "wild" : c.color; var face = document.createElement("span"); face.className = "pete-uno-face"; var oval = document.createElement("span"); oval.className = "pete-uno-oval"; if (f.size) oval.dataset.size = f.size; oval.textContent = f.mid; face.appendChild(oval); ["tl", "br"].forEach(function (at) { var corner = document.createElement("span"); corner.className = "pete-uno-corner"; corner.dataset.at = at; corner.textContent = f.corner; corner.setAttribute("aria-hidden", "true"); face.appendChild(corner); }); if (c.wild) { var wheel = document.createElement("span"); wheel.className = "pete-uno-wheel"; face.appendChild(wheel); if (c.color && c.color !== "wild") el.dataset.named = c.color; } if (c.wild && DRAWS[c.value]) el.dataset.glow = "1"; el.appendChild(face); el.setAttribute("aria-label", label(c)); return el; } var SPOKEN = { "+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten", "wild": "wild", "skip": "skip", "reverse": "reverse", "skip all": "skip everyone", "discard all": "discard all", "rev +4": "reverse and draw four", "roulette": "colour roulette", }; function label(c) { var v = SPOKEN[c.value] || c.value; if (c.wild) { var name = c.value === "wild" ? "wild" : "wild " + v; return name + (c.color && c.color !== "wild" ? ", played as " + c.color : ""); } return c.color + " " + v; } function back() { var el = document.createElement("div"); el.className = "pete-uno-card pete-uno-card-back"; var b = document.createElement("span"); b.className = "pete-uno-back"; el.appendChild(b); return el; } // ---- the board ------------------------------------------------------------- var FAN = 8; function live(v) { return !!v && (v.phase === "play" || v.phase === "drawn" || v.phase === "stack"); } function renderSeats(v) { seatsEl.innerHTML = ""; if (!v) return; var catchable = {}; (v.catchable || []).forEach(function (i) { catchable[i] = true; }); v.seats.forEach(function (s, i) { if (i === me) return; // you are the hand at the bottom, not a seat up here var el = document.createElement("div"); el.className = "pete-uno-seat"; el.dataset.seat = String(i); el.dataset.turn = v.turn === i && live(v) ? "1" : "0"; if (s.uno) el.dataset.uno = "1"; if (s.out) el.dataset.out = "1"; if (catchable[i]) { var got = document.createElement("button"); got.type = "button"; got.className = "pete-uno-catch"; got.dataset.catch = String(i); got.textContent = "Catch!"; got.setAttribute("aria-label", "Catch " + s.name + " β€” one card, never called"); got.addEventListener("click", function (e) { e.stopPropagation(); catchSeat(i); }); el.appendChild(got); } var fan = document.createElement("div"); fan.className = "pete-uno-fan"; var show = Math.min(s.cards, FAN); for (var n = 0; n < show; n++) { var b = back(); b.style.setProperty("--i", n); b.style.setProperty("--n", show); fan.appendChild(b); } el.appendChild(fan); var name = document.createElement("p"); name.className = "pete-uno-name"; name.textContent = s.name + (s.bot ? "" : " ●"); el.appendChild(name); var count = document.createElement("p"); count.className = "pete-uno-count"; count.dataset.count = ""; var stk = s.stack != null ? " Β· " + fmt(s.stack) : ""; count.textContent = (s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards")) + stk; el.appendChild(count); seatsEl.appendChild(el); }); } function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); } function seatAnchor(i) { if (i === me) return handEl; var el = seatEl(i); return el ? el.querySelector(".pete-uno-fan") : deckEl; } var shownHand = []; function key(c) { return c.color + "/" + c.value; } function paintHand(cards, playable, clickable) { cards = cards || []; var pool = shownHand.map(key); handEl.innerHTML = ""; var fresh = 0; cards.forEach(function (c, i) { var el = card(c, { button: true }); el.dataset.at = String(i); var ok = !!clickable && !!playable[i]; el.dataset.on = ok ? "1" : "0"; el.disabled = !ok || busy; if (ok) el.addEventListener("click", function () { pick(i, c); }); var was = pool.indexOf(key(c)); if (was >= 0) { pool.splice(was, 1); el.dataset.settled = "1"; } else { el.style.setProperty("--i", fresh++); } handEl.appendChild(el); }); shownHand = cards.slice(); } function renderHand(v) { if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; } var playable = {}; (v.playable || []).forEach(function (i) { playable[i] = true; }); paintHand(v.hand, playable, live(v) && v.turn === me); } function showHand(cards) { if (!cards) return; paintHand(cards, {}, false); countEl.textContent = cards.length + (cards.length === 1 ? " card β€” UNO!" : " cards"); } function renderPiles(v) { discardEl.innerHTML = ""; if (!v) { deckCntEl.textContent = "0"; colourEl.textContent = ""; if (feltEl) feltEl.dataset.c = ""; return; } deckCntEl.textContent = String(v.deck || 0); var dealt = (v.deck || 0) > 0 || (v.hand && v.hand.length) || (v.seats || []).some(function (s) { return s.cards > 0; }); if (dealt && v.top && v.top.value) { var top = card(v.top); top.classList.add("pete-uno-top"); discardEl.appendChild(top); colourEl.textContent = v.color; colourEl.dataset.c = v.color; if (feltEl) feltEl.dataset.c = v.color; } else { colourEl.textContent = ""; if (feltEl) feltEl.dataset.c = ""; } pending(live(v) ? (v.pending || 0) : 0); } function pending(n) { if (billEl) { billEl.textContent = n > 0 ? "+" + n + " on you" : ""; billEl.classList.toggle("hidden", n <= 0); } if (takeN) takeN.textContent = String(n); } function renderRail(v) { if (potEl) potEl.textContent = fmt(v ? v.pot : 0); if (stackEl) stackEl.textContent = v ? fmt(v.stack) : "β€”"; if (tableEl) tableEl.textContent = v && v.tier ? v.tier.name : ""; if (tableStackEl) tableStackEl.textContent = fmt(v ? v.stack : 0); if (boughtInEl) boughtInEl.textContent = fmt(v ? v.bought_in : 0); if (anteNoteEl) anteNoteEl.textContent = fmt(v && v.tier ? v.tier.ante : 0); } function renderTurn(v) { if (!v || !live(v)) { turnEl.textContent = ""; countEl.textContent = ""; return; } var yours = v.turn === me; var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…"; if (yours && v.phase === "drawn") who = "Play it, or keep it"; if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " β€” stack it, or take it"; turnEl.textContent = who; turnEl.dataset.you = yours ? "1" : "0"; var n = (v.hand || []).length; countEl.textContent = n + (n === 1 ? " card β€” UNO!" : " cards"); } // ---- phases ---------------------------------------------------------------- function verdict(v) { if (!v || v.winner == null) { verdictEl.classList.add("hidden"); return; } var text = ""; var ante = (v.seats[me] && v.seats[me].ante) || 0; if (v.outcome === "tie") { text = "Nobody went out β€” antes back."; } else if (v.winner === me) { var net = ((v.seats[me] && v.seats[me].won) || 0) - ante; var howYou = (v.seats[me] && v.seats[me].cards === 0) ? "You went out! πŸŽ‰" : "Last one standing! πŸŽ‰"; text = howYou + (net > 0 ? " +" + fmt(net) : ""); } else if (v.winner >= 0) { var w = v.seats[v.winner] || {}; text = (w.name || "They") + " took the pot." + (ante > 0 ? " βˆ’" + fmt(ante) : ""); } if (!text) { verdictEl.classList.add("hidden"); return; } verdictEl.textContent = text; verdictEl.classList.remove("hidden"); if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 }); else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose"); } var HINTS = { play: "Click a card that lights up. Nothing lights up? Draw one.", drawn: "You drew a card you can play. Play it, or keep it and pass.", stack: "Only draw cards will do here. Answer it, and it lands on somebody else β€” or take the lot.", }; function controls() { var lv = live(game); var yours = lv && game.turn === me; var drawn = lv && game.phase === "drawn"; var stack = lv && game.phase === "stack"; handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = busy || !yours || b.dataset.on !== "1"; }); seatsEl.querySelectorAll("[data-catch]").forEach(function (b) { b.disabled = busy || !yours; }); if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack; if (passBtn) passBtn.disabled = busy || !yours; if (takeBtn) takeBtn.disabled = busy || !yours; if (deckEl) deckEl.disabled = busy || !yours || drawn || stack; if (dealBtn) dealBtn.disabled = busy; if (leaveBtn) leaveBtn.disabled = busy; } function setPhase(v) { game = v; var seated = !!v; var lv = live(v); var yours = lv && v.turn === me; var drawn = lv && v.phase === "drawn"; var stack = lv && v.phase === "stack"; if (sittingEl) sittingEl.classList.toggle("hidden", seated); if (chatEl) chatEl.classList.toggle("hidden", !seated); if (actingEl) actingEl.classList.toggle("hidden", !yours); if (betweenEl) betweenEl.classList.toggle("hidden", !(seated && !lv)); hideWild(); hideGate(); if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack); if (takeBtn) takeBtn.classList.toggle("hidden", !stack); var canPass = drawn && !(v && v.tier && v.tier.no_mercy); if (passBtn) passBtn.classList.toggle("hidden", !canPass); if (hintEl && yours) { hintEl.textContent = (v.catchable || []).length ? "Somebody's down to one card and never said so. Catch them before you play." : (HINTS[v.phase] || HINTS.play); } controls(); if (!v || v.winner == null) verdictEl.classList.add("hidden"); } function paint(v) { if (v && v.your_seat != null) me = v.your_seat; renderSeats(v); renderPiles(v); renderHand(v); renderRail(v); renderTurn(v); setPhase(v); if (v && !live(v) && v.winner != null) verdict(v); } // ---- the script ------------------------------------------------------------ function throwCard(node, from, to, opts) { opts = opts || {}; return FX.flyNode(node, from, to, { duration: opts.duration || 380, lift: opts.lift == null ? 0.7 : opts.lift, spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin, fromScale: opts.fromScale == null ? 0.9 : opts.fromScale, delay: opts.delay || 0, index: opts.index || 0, sound: opts.sound === undefined ? "card" : opts.sound, }); } function bump(seat, left) { if (seat === me || left == null) return; var el = seatEl(seat); if (!el) return; var fan = el.querySelector(".pete-uno-fan"); var count = el.querySelector("[data-count]"); var buried = el.dataset.out === "1"; if (count) { var stk = count.textContent.indexOf("Β·") >= 0 ? count.textContent.slice(count.textContent.indexOf("Β·")) : ""; count.textContent = (buried ? "Buried" : left + (left === 1 ? " card" : " cards")) + (stk ? " " + stk : ""); } if (!fan) return; var show = Math.min(left, FAN); while (fan.children.length > show) fan.removeChild(fan.lastChild); while (fan.children.length < show) fan.appendChild(back()); Array.prototype.forEach.call(fan.children, function (b, i) { b.style.setProperty("--i", i); b.style.setProperty("--n", fan.children.length); }); el.dataset.uno = left === 1 ? "1" : "0"; } function spotlight(seat) { seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) { el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0"; }); } function badge(seat, text, tone) { var host = seat === me ? handEl : seatEl(seat); if (!host || reduced) return Promise.resolve(); var b = document.createElement("span"); b.className = "pete-uno-badge"; b.dataset.tone = tone || ""; b.textContent = text; host.appendChild(b); return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); }); } // play walks the server's script for a move the acting player just made. function play(view) { var events = view.uno_events || []; var final = view.uno; if (final && final.your_seat != null) me = final.your_seat; var chain = Promise.resolve(); events.forEach(function (e, n) { chain = chain.then(function () { switch (e.kind) { case "ante": return; // the antes are tallied into the pot the deal paints case "deal": FX.sfx("shuffle"); for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c }); paint(final); return wait(320); case "play": { spotlight(e.seat); var node = card(e.card); var from = seatAnchor(e.seat); if (e.seat === me && played >= 0) { var liveEl = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]'); if (liveEl) liveEl.style.visibility = "hidden"; played = -1; } bump(e.seat, e.left); return throwCard(node, from, discardEl, { index: n }).then(function () { discardEl.innerHTML = ""; var top = card(e.card); top.classList.add("pete-uno-top", "pete-uno-land"); discardEl.appendChild(top); if (e.color) { colourEl.textContent = e.color; colourEl.dataset.c = e.color; if (feltEl) feltEl.dataset.c = e.color; } showHand(e.hand); return wait(e.seat === me ? 120 : 300); }); } case "draw": case "forced": { spotlight(e.seat); var to = seatAnchor(e.seat); var backs = []; for (var i = 0; i < Math.min(e.n, 4); i++) { backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" })); } var punished = e.kind === "forced"; if (punished) { FX.sfx("bad"); pending(0); badge(e.seat, "+" + e.n, "bad"); } return Promise.all(backs).then(function () { bump(e.seat, e.left); showHand(e.hand); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); if (e.seat === me && e.card) return wait(160); return wait(punished ? 380 : 180); }); } case "caught": { spotlight(e.seat); var mine = e.seat === me; var caughtBy = (game && game.seats && game.seats[e.by]) || {}; var whom = mine ? "Caught! " + (caughtBy.name || "They") + " saw it" : "Caught! +" + e.n; FX.sfx(mine ? "bad" : "catch"); var lands = []; var at = seatAnchor(e.seat); for (var k = 0; k < e.n; k++) { lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" })); } badge(e.seat, whom, "bad"); return Promise.all(lands).then(function () { bump(e.seat, e.left); showHand(e.hand); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); return wait(520); }); } case "miscall": { FX.sfx("bad"); var wrong = []; for (var w = 0; w < e.n; w++) { wrong.push(throwCard(back(), deckEl, seatAnchor(e.seat), { index: w, delay: w * 100, sound: "deal" })); } badge(e.seat, "They called it. +" + e.n, "bad"); return Promise.all(wrong).then(function () { bump(e.seat, e.left); showHand(e.hand); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); return wait(520); }); } case "stack": pending(e.n); spotlight(e.seat); FX.sfx("bad"); return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); }); case "skipall": return badge(e.seat, "Everyone skipped").then(function () { return wait(240); }); case "discard": { if (e.seat === me && e.color) { handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) { el.style.visibility = "hidden"; }); } bump(e.seat, e.left); var dumpFrom = seatAnchor(e.seat); var dumped = []; for (var d = 0; d < Math.min(e.n, 4); d++) { dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 })); } badge(e.seat, "βˆ’" + e.n + " " + (e.color || "")); return Promise.all(dumped).then(function () { showHand(e.hand); return wait(220); }); } case "roulette": { spotlight(e.seat); var spinTo = seatAnchor(e.seat); var spun = []; for (var r = 0; r < Math.min(e.n, 4); r++) { spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 })); } badge(e.seat, "Roulette +" + e.n, "bad"); return Promise.all(spun).then(function () { bump(e.seat, e.left); showHand(e.hand); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); return wait(400); }); } case "mercy": { var grave = seatEl(e.seat); if (grave) grave.dataset.out = "1"; bump(e.seat, 0); showHand(e.hand); pending(0); FX.sfx("bad"); return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); }); } case "skip": return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); }); case "reverse": if (feltEl) feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1"; return badge(e.seat, "Reverse").then(function () { return wait(60); }); case "uno": FX.sfx("uno"); return badge(e.seat, "UNO!", "uno"); case "reshuffle": deckCntEl.textContent = String(e.n); deckEl.classList.add("pete-uno-shuffle"); FX.sfx("shuffle"); return wait(420).then(function () { deckEl.classList.remove("pete-uno-shuffle"); }); case "pass": spotlight(e.seat); return wait(140); case "settle": case "sit": case "leave": return; } }); }); return chain.then(function () { if (!final) { paint(null); return; } paint(final); if (!live(final) && final.winner != null) verdict(final); }); } // ---- moves ------------------------------------------------------------------ function pick(i, c) { if (busy || !live(game) || game.turn !== me) return; if (c.wild) { askColour(i); return; } offer(i, 0); } function offer(i, color) { if (game && (game.uno_at || []).indexOf(i) >= 0) { openGate(i, color); return; } move({ kind: "play", index: i, color: color }); } function openGate(i, color) { gate = { index: i, color: color }; gateEl.classList.remove("hidden"); if (timerEl) { timerEl.style.animation = "none"; void timerEl.offsetWidth; timerEl.style.animation = ""; timerEl.style.animationDuration = CALL_MS + "ms"; } if (callBtn) callBtn.focus(); gateTimer = setTimeout(function () { shut(false); }, CALL_MS); } function shut(called) { if (!gate) return; var g = gate; gate = null; clearTimeout(gateTimer); gateTimer = 0; gateEl.classList.add("hidden"); move({ kind: "play", index: g.index, color: g.color, uno: called }); } function hideGate() { if (gateTimer) clearTimeout(gateTimer); gateTimer = 0; gate = null; if (gateEl) gateEl.classList.add("hidden"); } if (callBtn) callBtn.addEventListener("click", function () { shut(true); }); function catchSeat(seat) { if (busy || !live(game) || game.turn !== me) return; if ((game.catchable || []).indexOf(seat) < 0) return; move({ kind: "catch", seat: seat }); } function askColour(i) { pendingWild = i; wildEl.classList.remove("hidden"); } function hideWild() { pendingWild = -1; if (wildEl) wildEl.classList.add("hidden"); } var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 }; root.querySelectorAll("[data-colour-pick]").forEach(function (b) { b.addEventListener("click", function () { if (busy || pendingWild < 0) return; var i = pendingWild; var c = COLOURS[b.dataset.colourPick]; hideWild(); offer(i, c); }); }); var cancelWild = root.querySelector("[data-colour-cancel]"); if (cancelWild) cancelWild.addEventListener("click", hideWild); function move(body) { played = body.kind === "play" ? body.index : -1; send("/api/games/uno/move", body, gameMsgEl); } if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); }); if (deckEl) deckEl.addEventListener("click", function () { drawOne(); }); if (passBtn) passBtn.addEventListener("click", function () { if (busy || !live(game) || game.turn !== me || game.phase !== "drawn") return; move({ kind: "pass" }); }); function drawOne() { if (busy || !game || game.phase !== "play" || game.turn !== me) return; move({ kind: "draw" }); } if (takeBtn) takeBtn.addEventListener("click", function () { if (busy || !game || game.turn !== me || game.phase !== "stack") return; move({ kind: "take" }); }); if (dealBtn) dealBtn.addEventListener("click", function () { if (busy) return; move({ kind: "deal" }); }); if (leaveBtn) leaveBtn.addEventListener("click", function () { if (busy) return; busy = true; say("", null, betweenMsg); G.post("/api/games/uno/leave", {}) .then(function (v) { G.apply(v); unseated(); paint(null); loadLobby(); }) .catch(function (err) { say(err.message, "bad", betweenMsg); }) .then(function () { busy = false; controls(); }); }); // ---- talking to the table -------------------------------------------------- // send takes the table away for the whole move β€” the request and the script that // comes back with it β€” so a click mid-animation can't move against a board the // server has already left behind. busy comes off at the end of the script. function send(path, body, where) { if (busy) return; busy = true; say("", null, where); controls(); return G.post(path, body) .then(function (view) { G.apply(view); return play(view); }) .catch(function (err) { say(err.message, "bad", where); return G.refresh().then(function (v) { if (v && v.uno) paint(v.uno); else paint(null); }); }) .then(function () { busy = false; controls(); if (pendingSync) { pendingSync = false; sync(); } }); } // ---- the live stream ------------------------------------------------------- function seated() { loadChat(); connectLive(); } function unseated() { disconnectLive(); if (chatLog) chatLog.innerHTML = ""; chatSeen = {}; } function connectLive() { if (stream || !window.EventSource) return; stream = new EventSource("/api/games/uno/stream"); stream.onmessage = function (ev) { var msg; try { msg = JSON.parse(ev.data); } catch (e) { return; } if (msg.type === "chat") addChat(msg.line); else if (msg.type === "table") sync(); }; stream.addEventListener("sync", function () { sync(); }); } function disconnectLive() { if (stream) { stream.close(); stream = null; } } // sync repaints from the authoritative table β€” a snapshot, no animation. Held // while a move of ours is animating, and applied once it finishes. function sync() { if (busy) { pendingSync = true; return; } G.refresh().then(function (v) { if (!v) return; if (v.uno) paint(v.uno); else { paint(null); } }); } // ---- chat ------------------------------------------------------------------ function loadChat() { fetch("/api/games/uno/chat", { headers: { "Accept": "application/json" } }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (data) { if (!data || !chatLog) return; chatLog.innerHTML = ""; chatSeen = {}; (data.chat || []).forEach(addChat); }) .catch(function () {}); } function addChat(line) { if (!chatLog || !line) return; // Your own line comes back twice β€” once from the POST that sent it, once // echoed over your own stream β€” so drop any id already on the rail. if (line.id) { if (chatSeen[line.id]) return; chatSeen[line.id] = true; } var row = document.createElement("div"); row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : ""); var who = document.createElement("span"); who.className = "pete-chat-who"; who.textContent = line.name; var body = document.createElement("span"); body.className = "pete-chat-body"; body.textContent = line.body; row.appendChild(who); row.appendChild(body); chatLog.appendChild(row); chatLog.scrollTop = chatLog.scrollHeight; } if (chatForm) chatForm.addEventListener("submit", function (e) { e.preventDefault(); var body = (chatInput.value || "").trim(); if (!body) return; chatInput.value = ""; G.post("/api/games/uno/say", { body: body }) .then(function (line) { addChat(line); }) .catch(function () { chatInput.value = body; }); }); // ---- sitting down ---------------------------------------------------------- var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" }; function pickRules(which, slug) { root.querySelectorAll("[data-rules]").forEach(function (b) { b.dataset.on = b.dataset.rules === which ? "1" : "0"; }); root.querySelectorAll("[data-grid]").forEach(function (g) { g.classList.toggle("hidden", g.dataset.grid !== which); }); root.querySelectorAll("[data-note]").forEach(function (p) { p.classList.toggle("hidden", p.dataset.note !== which); }); var el = root.querySelector('[data-tier="' + (slug || DEFAULT_TABLE[which]) + '"]'); if (el) pickTier(el); } root.querySelectorAll("[data-rules]").forEach(function (b) { b.addEventListener("click", function () { if (!busy) pickRules(b.dataset.rules); }); }); function pickTier(btn) { tier = btn; root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; }); syncSit(); } root.querySelectorAll("[data-tier]").forEach(function (b) { b.addEventListener("click", function () { if (!busy) pickTier(b); }); }); // syncSit keeps the buy-in slider inside the table's range and the sit button // honest about whether you can afford the low end. function syncSit() { if (!tier || !buySlider) return; var min = Number(tier.dataset.min), max = Number(tier.dataset.max); buySlider.min = String(min); buySlider.max = String(max); buySlider.step = String(Math.max(1, Math.round((max - min) / 100))); var cur = Number(buySlider.value); if (!cur || cur < min || cur > max) buySlider.value = String(min); var money = G.view(); var chips = money ? money.chips : 0; if (buyLabel) buyLabel.textContent = fmt(Number(buySlider.value)); if (buyNote) buyNote.textContent = min + "–" + max + " Β· you have " + fmt(chips); if (sitBtn) sitBtn.disabled = busy || !tier || chips < Number(buySlider.value); } if (buySlider) buySlider.addEventListener("input", syncSit); if (sitBtn) sitBtn.addEventListener("click", function () { if (busy || !tier) return; busy = true; say("", null, tableMsg); G.post("/api/games/uno/sit", { tier: tier.dataset.tier, buyin: Number(buySlider.value) }) .then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. Deal when you're ready.", null, betweenMsg); }) .catch(function (err) { say(err.message, "bad", tableMsg); }) .then(function () { busy = false; controls(); }); }); // ---- the lobby ------------------------------------------------------------- function loadLobby() { if (!lobbyEl) return; fetch("/api/games/uno/tables", { headers: { "Accept": "application/json" } }) .then(function (res) { return res.ok ? res.json() : null; }) .then(function (data) { renderLobby((data && data.tables) || []); }) .catch(function () {}); } function renderLobby(tables) { lobbyEl.innerHTML = ""; if (lobbyWrap) lobbyWrap.classList.toggle("hidden", tables.length === 0); tables.forEach(function (t) { var stakes = root.querySelector('[data-tier="' + t.tier + '"]'); var btn = document.createElement("button"); btn.type = "button"; btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition"; var lbl = document.createElement("span"); lbl.className = "font-display font-bold"; lbl.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier; var seats = document.createElement("span"); seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums"; seats.textContent = t.humans + "/" + t.seats + " seated"; btn.appendChild(lbl); btn.appendChild(seats); btn.addEventListener("click", function () { joinTable(t, stakes); }); lobbyEl.appendChild(btn); }); } function joinTable(t, stakes) { if (busy) return; var min = stakes ? Number(stakes.dataset.min) : Number(buySlider.value); var max = stakes ? Number(stakes.dataset.max) : Number(buySlider.value); var buyIn = Math.min(max, Math.max(min, Number(buySlider.value))); busy = true; say("", null, tableMsg); G.post("/api/games/uno/sit", { table: t.id, buyin: buyIn }) .then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. The next hand deals when the table is ready.", null, betweenMsg); }) .catch(function (err) { say(err.message, "bad", tableMsg); }) .then(function () { busy = false; controls(); }); } // ---- boot ------------------------------------------------------------------ pickRules("normal"); var resumed = false; G.onUpdate(function () { if (resumed) return; resumed = true; G.refresh().then(function (v) { if (v && v.uno) { paint(v.uno); seated(); } else { paint(null); loadLobby(); syncSit(); } }); }); })();