// The UNO table. // // Same bargain as every other table in the room: the browser holds no game. It // sends one move, and what comes back is that move *and every bot turn it handed // off to*, as a script of events. So a round trip here is a whole lap of the // table, and this file's main job is to play that lap back slowly enough that // you can see what happened to you. // // Two rules carried over from the tables that came before, both load-bearing: // // The number under the pile is a readout of the pile (PeteFX.spot owns that), and // the chip bar does not move until the chips that justify it have landed. So a // settling game holds the money back until the payout has physically swept home // β€” a counter that pays you before the last card goes down has told you the // ending. // // And the browser never learns a bot's hand. It gets a *count*. Every fan of // backs you see up there is drawn from a number, because a number is all that // crossed the wire. (function () { "use strict"; var root = document.querySelector("[data-uno]"); if (!root) return; var FX = window.PeteFX; 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 paysEl = root.querySelector("[data-pays]"); var meterEl = root.querySelector("[data-meter]"); var tableEl = root.querySelector("[data-table-name]"); var wildEl = root.querySelector("[data-wild]"); // Not [data-pending]: that is the chip bar's, it lives inside this root, and it // comes first in the document. See the note in uno.html. var billEl = root.querySelector("[data-bill]"); var betting = root.querySelector("[data-betting]"); var playing = root.querySelector("[data-playing]"); 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 betAmount = root.querySelector("[data-bet-amount]"); var startBtn = root.querySelector("[data-start]"); var msgEl = root.querySelector("[data-table-msg]"); var gameMsgEl = root.querySelector("[data-game-msg]"); var purseEl = document.querySelector("[data-chips]"); var spotEl = root.querySelector("[data-spot]"); var houseEl = root.querySelector("[data-house]"); var spot = FX.spot({ spot: spotEl, stack: root.querySelector("[data-stack]"), total: root.querySelector("[data-spot-total]"), }); var bet = 0; // what you're building between games var busy = false; var game = null; // the game as the server last described it var tier = "table"; var pendingWild = -1; // the wild you clicked, waiting on a colour var played = -1; // the card you just played, so the script lifts that one out // of the hand and not merely the first one that lit up 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 || msgEl; if (!el) return; if (!text) { el.classList.add("hidden"); return; } el.textContent = text; el.classList.remove("hidden"); el.style.color = tone === "bad" ? "#cc3d4a" : ""; } // ---- drawing the cards ----------------------------------------------------- // FACES is how each printed face is drawn. The engine sends a word ("skip", // "+2", "discard all"); this is the picture of it. // // The corner is not always the same mark as the middle. "DISCARD ALL" fits // across an oval and does not fit in a corner, so the long faces carry a short // mark for the corners β€” which is what a real deck does too. `size` is a class, // never a height: a card is sized by --uno-h/--uno-w and nothing else, and a // face that sets its own font in pixels is a face that breaks on a phone. var FACES = { "skip": { mid: "🚫", corner: "🚫" }, "reverse": { mid: "⇄", corner: "⇄" }, "+2": { mid: "+2", corner: "+2" }, "wild": { mid: "β˜…", corner: "β˜…" }, "+4": { mid: "+4", corner: "+4" }, // No Mercy. "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" }, // The roulette names a colour and turns cards over until it comes up. The // wheel behind the oval is already the picture of that; the mark is the // question the wheel is asking. "roulette": { mid: "?", corner: "?" }, }; function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; } // The faces that make somebody draw. On a wild these are the cards worth the // most and worth watching for, so they get the glow β€” and it is the one thing // that tells the *wild* +4 apart from No Mercy's coloured +4 at a glance. var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true }; // card builds one UNO card. The oval across the middle at an angle is the whole // look of the thing β€” without it a card reads as a coloured button. 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); // The corners, as printed. ["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); }); // A wild is four quadrants of colour β€” and once it has been played as a // colour, that colour is the one it wears on the pile. 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; } // The wild draws glow. They are the cards that decide a game β€” a +4 you were // holding is four cards somebody else is about to be holding β€” and in No Mercy // there are four of them to tell apart from the coloured +4 sitting next to // them in the same hand. The glow is what says "this one is the nasty one". if (c.wild && DRAWS[c.value]) el.dataset.glow = "1"; el.appendChild(face); el.setAttribute("aria-label", label(c)); return el; } // label is what a screen reader says. Note "+4" is two different cards β€” No // Mercy prints a coloured one next to the wild one β€” so the name comes from the // face *and* whether it's a wild, never from the face alone. 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; // not "wild wild" 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 ------------------------------------------------------------- // FAN is the most backs a bot's hand draws, however many it holds. Past this // the fan is unreadable and the number beside it is doing the work anyway. var FAN = 8; function renderSeats(v) { seatsEl.innerHTML = ""; if (!v) return; v.seats.forEach(function (s, i) { if (s.you) 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 && v.phase !== "done" ? "1" : "0"; if (s.uno) el.dataset.uno = "1"; if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back 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; el.appendChild(name); var count = document.createElement("p"); count.className = "pete-uno-count"; count.dataset.count = ""; count.textContent = s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards"); el.appendChild(count); seatsEl.appendChild(el); }); } function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); } // Where a card flies to or from, for a seat. Yours is the hand; a bot's is its // fan; the deck is the deck. function seatAnchor(i) { if (i === 0) return handEl; var el = seatEl(i); return el ? el.querySelector(".pete-uno-fan") : deckEl; } function renderHand(v) { handEl.innerHTML = ""; if (!v || !v.hand) return; var playable = {}; (v.playable || []).forEach(function (i) { playable[i] = true; }); var yours = v.turn === 0 && v.phase !== "done"; v.hand.forEach(function (c, i) { var el = card(c, { button: true }); el.style.setProperty("--i", i); el.dataset.at = String(i); var ok = yours && playable[i]; el.dataset.on = ok ? "1" : "0"; el.disabled = !ok || busy; if (ok) el.addEventListener("click", function () { pick(i, c); }); handEl.appendChild(el); }); } function renderPiles(v) { discardEl.innerHTML = ""; if (!v) { deckCntEl.textContent = "0"; colourEl.textContent = ""; root.querySelector(".pete-uno").dataset.c = ""; return; } deckCntEl.textContent = String(v.deck); var top = card(v.top); top.classList.add("pete-uno-top"); discardEl.appendChild(top); // The colour in play, which after a wild is not the colour of the card you // are looking at. So it is said in words, and the felt takes a tint of it β€” // this is the single most missable fact on the table. colourEl.textContent = v.color; colourEl.dataset.c = v.color; feltEl.dataset.c = v.color; pending(v.phase === "done" ? 0 : v.pending || 0); } var feltEl = root.querySelector(".pete-uno"); // pending is the bill a stack has run up. It is on the felt, on the button and // in the turn line, because it is the only thing on the table while it stands: // a player who misses it plays into a hand that is about to grow by ten. 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 (!v) { paysEl.textContent = "β€”"; meterEl.dataset.cold = "1"; tableEl.textContent = ""; return; } paysEl.textContent = (v.pays || 0).toLocaleString(); meterEl.dataset.cold = v.phase === "done" ? "1" : "0"; tableEl.textContent = v.tier.name + " Β· " + v.tier.base.toFixed(1) + "Γ—"; } function renderTurn(v) { if (!v || v.phase === "done") { turnEl.textContent = ""; countEl.textContent = ""; return; } var yours = v.turn === 0; 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 ---------------------------------------------------------------- var VERDICTS = { won: "You went out! πŸŽ‰", lost: "Beaten to it.", stuck: "Nobody could move.", }; function verdict(v) { var text = VERDICTS[v.outcome] || ""; if (v.outcome === "lost" && v.winner > 0 && v.seats[v.winner]) { text = v.seats[v.winner].name + " went out first."; } // No Mercy has two endings the normal deck hasn't got: you can lose without // anybody going out (the mercy rule got you), and win without going out at all // (it got everybody else). if (v.outcome === "lost" && v.winner < 0) text = "Buried. Twenty-five cards and you're done."; if (v.outcome === "won" && v.seats[0] && v.seats[0].cards > 0) text = "Last one standing! πŸŽ‰"; if (!text) { verdictEl.classList.add("hidden"); return; } if (v.net > 0) text += " +" + v.net.toLocaleString(); else if (v.net < 0) text += " " + v.net.toLocaleString(); verdictEl.textContent = text; verdictEl.classList.remove("hidden"); if (v.outcome === "won") FX.burst(verdictEl, { count: 34 }); } // controls is the one place that decides what you can click: whose turn the // server says it is, and whether a move of yours is still in flight. Both // halves matter, and they used to be spread across three functions that each // knew half of it β€” which is how the table came to unlock itself in the middle // of a bot's turn. function controls() { var live = !!game && game.phase !== "done"; var yours = live && game.turn === 0; var drawn = live && game.phase === "drawn"; var stack = live && game.phase === "stack"; handEl.querySelectorAll(".pete-uno-card").forEach(function (b) { b.disabled = busy || !yours || b.dataset.on !== "1"; }); // Under a stack you cannot draw. The deck is not a way out of a bill: the two // moves that exist are answering it with a draw card or taking the lot. 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; } // HINTS is the line above the buttons. A game with a stack in it has a rule the // player has just met for the first time, and the table should say what it is // rather than leave them clicking a hand where nothing lights up. 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 setPhase(v) { game = v; var live = !!v && v.phase !== "done"; var drawn = live && v.phase === "drawn"; var stack = live && v.phase === "stack"; betting.classList.toggle("hidden", live); playing.classList.toggle("hidden", !live); hideWild(); if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack); if (takeBtn) takeBtn.classList.toggle("hidden", !stack); // No Mercy has no pass: you drew until you found a card that plays, and // playing it is the price of having drawn. The server refuses one anyway; // this is the table not offering a button that cannot work. var canPass = drawn && !(v && v.tier && v.tier.no_mercy); if (passBtn) passBtn.classList.toggle("hidden", !canPass); if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play; if (!live && v) rulesFor(v); // it's over: leave the panel where the game was controls(); if (!v || !v.outcome) verdictEl.classList.add("hidden"); } // paint puts the board up with no animation: the resume path, after a reload or // a redeploy. Whatever the server says is on the table is on the table. function paint(v) { renderSeats(v); renderPiles(v); renderHand(v); renderRail(v); renderTurn(v); spot.render(v && v.phase !== "done" ? v.bet : 0); setPhase(v); } // ---- the script ------------------------------------------------------------ // throwCard sends a card from one place to another across the felt. 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, }); } // bump keeps a bot's fan honest during the script: the server's count is the // truth, but between events the fan has to grow and shrink as cards move, or // the flight lands on a pile that hasn't changed. function bump(seat, left) { if (seat === 0 || 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"; // an empty hand here is a grave, not a win if (count) count.textContent = buried ? "Buried" : left + (left === 1 ? " card" : " cards"); 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"; }); } // badge floats a word off a seat: SKIPPED, UNO!, +4. The events already say // these things; the badge is what makes them land on somebody. function badge(seat, text, tone) { var host = seat === 0 ? 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. On a live game the money is already right β€” // your stake left your pile when you pressed Deal, and it's on the spot β€” so // the chip bar can catch up immediately. On a settling one it waits. function play(view, money) { var events = view.uno_events || []; var final = view.uno; var settles = !!final && final.phase === "done"; var chain = Promise.resolve(); if (!settles) money(); events.forEach(function (e, n) { chain = chain.then(function () { switch (e.kind) { case "deal": // The deal isn't animated card by card: seven cards to each of four // seats is 28 flights and a player who wants to play. The hand fans in // on its own (CSS), which reads as being dealt without taking as long. paint(final); return wait(320); case "play": { spotlight(e.seat); var node = card(e.card); var from = seatAnchor(e.seat); // Your own card leaves the hand it was in, so the hand has to lose it // before the flight or the card is briefly in two places. if (e.seat === 0 && played >= 0) { var live = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]'); if (live) live.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; feltEl.dataset.c = e.color; } return wait(e.seat === 0 ? 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 })); } var punished = e.kind === "forced"; // A forced draw is also how a stack ends: somebody stopped answering // and paid the bill, so the bill is gone. if (punished) { pending(0); badge(e.seat, "+" + e.n, "bad"); } return Promise.all(backs).then(function () { bump(e.seat, e.left); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); // Your own drawn card comes face up β€” it's yours, and the server // sent its face for exactly this. if (e.seat === 0 && e.card) return wait(160); return wait(punished ? 380 : 180); }); } case "stack": // The bill has moved on to somebody, and N is what it stands at now β€” // not what was just added to it. A +2 answered with a +10 is a seat // looking at twelve cards. pending(e.n); spotlight(e.seat); return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); }); case "skipall": // The turn does not move: it comes straight back to the seat that // played it, which is the whole card. return badge(e.seat, "Everyone skipped").then(function () { return wait(240); }); case "discard": { // A whole colour left a hand at once. If it was your hand, those cards // are on the table in front of you β€” so they go before the flight, or // they sit there looking like they were dumped twice. if (e.seat === 0 && 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 () { return wait(220); }); } case "roulette": { // They turn cards over until your colour comes up, and they keep every // one of them. N is how deep it went. 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); deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n)); return wait(400); }); } case "mercy": { // Twenty-five cards, and they are out of the game for good. It is the // one beat in this script allowed to take its time. var grave = seatEl(e.seat); if (grave) grave.dataset.out = "1"; bump(e.seat, 0); pending(0); // a dead seat pays no bill and leaves none behind 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": feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1"; return badge(e.seat, "Reverse").then(function () { return wait(60); }); case "uno": return badge(e.seat, "UNO!", "uno"); case "reshuffle": // The discard has just gone back under, so the counter has to as // well: the draws that follow in this same script count down from it, // and counting down from a deck that still reads empty gets nowhere. deckCntEl.textContent = String(e.n); deckEl.classList.add("pete-uno-shuffle"); return wait(420).then(function () { deckEl.classList.remove("pete-uno-shuffle"); }); case "pass": spotlight(e.seat); return wait(140); case "settle": return; } }); }); return chain.then(function () { if (!final) { paint(null); money(); return; } if (!settles) { paint(final); return; } // Over: the board settles, the money moves, and only then does the bar // catch up. renderSeats(final); renderPiles(final); renderHand(final); renderTurn(final); renderRail(final); verdict(final); playing.classList.add("hidden"); return settleChips(final) .then(money) .then(function () { return standing(final.bet); }) .then(function () { setPhase(final); }); }); } // ---- the money ------------------------------------------------------------- function settleChips(final) { var payout = final.payout || 0; if (payout <= 0) return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true }); // Not `back`: that is the card back up there, and a number wearing its name // is a landmine for whoever next wants a face-down card in here. var profit = payout - final.bet; return spot .pour(houseEl, profit, { gap: 60 }) .then(function () { return wait(profit > 0 ? 380 : 200); }) .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); } // standing puts the stake back on the spot for the next game, the way every // other table in the room leaves your bet up. pour grows the pile from what it // is told is already there, so the amount is never set first β€” that bug printed // double the stake under trivia's chips for a day. function standing(amount) { var money = window.PeteGames.view(); if (!amount || !money || money.chips < amount) { bet = 0; showBet(); return; } bet = amount; showBet(); return spot.pour(purseEl, amount); } // ---- moves ------------------------------------------------------------------ function pick(i, c) { if (busy || !game || game.phase === "done" || game.turn !== 0) return; if (c.wild) { askColour(i); return; } move({ kind: "play", index: i }); } function askColour(i) { pendingWild = i; wildEl.classList.remove("hidden"); } function hideWild() { pendingWild = -1; if (wildEl) wildEl.classList.add("hidden"); } // COLOURS maps the colour a player names to the number the engine calls it. // Wild is 0 there on purpose β€” a move with no colour on it must not quietly // mean red β€” so these start at 1 and this table is the only place that knows. 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(); move({ kind: "play", index: i, color: 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 || !game || game.turn !== 0 || game.phase !== "drawn") return; move({ kind: "pass" }); }); } function drawOne() { if (busy || !game || game.phase !== "play" || game.turn !== 0) return; move({ kind: "draw" }); } // Taking the stack: you give in, you eat every card it has run up, and you lose // your turn. It is a move of its own, not a draw β€” see the engine's MoveTake. if (takeBtn) { takeBtn.addEventListener("click", function () { if (busy || !game || game.turn !== 0 || game.phase !== "stack") return; move({ kind: "take" }); }); } // ---- talking to the table ---------------------------------------------------- // send takes the table away for the whole move β€” not just the request, but the // script that comes back with it. A lap of this table is seconds of animation, // and for every one of them the game on screen is a game the server has already // moved past. Clicking a card in there would send a move against a board that // no longer exists. So busy comes off at the end of the script, not the end of // the request, which is what the other tables do too. function send(path, body, where) { if (busy) return; busy = true; say("", null, where); controls(); return window.PeteGames.post(path, body) .then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); }) .catch(function (err) { say(err.message, "bad", where); return window.PeteGames.refresh().then(function (v) { if (v && v.uno) paint(v.uno); else { paint(null); spot.render(0); } }); }) .then(function () { busy = false; controls(); showBet(); }); } // ---- betting ------------------------------------------------------------------ function showBet() { betAmount.textContent = bet.toLocaleString(); var money = window.PeteGames.view(); if (startBtn) startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet; } function pickTier(slug) { tier = slug; root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b.dataset.tier === slug ? "1" : "0"; }); showBet(); } root.querySelectorAll("[data-tier]").forEach(function (b) { b.addEventListener("click", function () { if (busy) return; pickTier(b.dataset.tier); }); }); // The rules switch. It is a different dial from the table size β€” the size is // what you're paid for, the deck is what you're playing with β€” so throwing it // swaps the three tables for the other three rather than becoming a fourth one. 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); }); pickTier(slug || DEFAULT_TABLE[which]); } root.querySelectorAll("[data-rules]").forEach(function (b) { b.addEventListener("click", function () { if (busy) return; pickRules(b.dataset.rules); }); }); // A game that just ended leaves the betting panel on the table it was played at: // the commonest thing a player wants after a hand of No Mercy is another one. function rulesFor(v) { if (!v || !v.tier) return; pickRules(v.tier.no_mercy ? "nomercy" : "normal", v.tier.slug); } // The chip you click is the chip that flies. Scoped to buttons: the bare // [data-chip] spans in the rack are the house's, and the house is not betting. root.querySelectorAll("button[data-chip]").forEach(function (btn) { btn.addEventListener("click", function () { if (busy) return; var d = parseInt(btn.dataset.chip, 10); var money = window.PeteGames.view(); if (money && bet + d > money.chips) { say("You haven't got that many chips.", "bad"); return; } bet += d; showBet(); var target = bet; spot.amount = bet; FX.fly(btn, spotEl, { denom: d }).then(function () { if (bet >= target) spot.render(target); // unless Clear got there first }); }); }); var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { if (busy) return; if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 }); bet = 0; showBet(); }); } if (startBtn) { startBtn.addEventListener("click", function () { if (busy) return; if (bet <= 0) { say("Put something on it first.", "bad"); return; } // The stake sits on the spot for the whole game: it is what's riding on you // going out first. send("/api/games/uno/start", { bet: bet, tier: tier }); }); } pickRules("normal"); var resumed = false; window.PeteGames.onUpdate(function (v) { if (!resumed) { resumed = true; if (v.uno) { paint(v.uno); if (v.uno.phase === "done") verdict(v.uno); } else { paint(null); } } showBet(); }); })();