Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "discard all" takes every card of its colour with it, so a six-card hand can land on one, and a browser subtracting one from six walks you into a catch it never warned you about. And the room was silent. Every sound in here is *made* — an oscillator, a burst of filtered noise, an envelope — the same bargain the weather engine takes with its clouds. A card is a slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
1127 lines
44 KiB
JavaScript
1127 lines
44 KiB
JavaScript
// 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]");
|
||
var gateEl = root.querySelector("[data-unogate]");
|
||
var callBtn = root.querySelector("[data-uno-call]");
|
||
var timerEl = root.querySelector("[data-uno-timer]");
|
||
// 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
|
||
|
||
// The UNO call. CALL_MS is how long you get to say it once the card is on its
|
||
// way down — long enough that nobody playing the game misses it, short enough
|
||
// that it is still a thing you have to *do*. The engine has the other half of
|
||
// this rule (see call.go); this is only the window.
|
||
var CALL_MS = 2600;
|
||
var gate = null; // the play waiting on a call: {index, color}
|
||
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 || 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;
|
||
var catchable = {};
|
||
(v.catchable || []).forEach(function (i) { catchable[i] = true; });
|
||
|
||
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
|
||
|
||
// One card, and it never said so. This button is the only thing on the felt
|
||
// that gives it away — a bot that forgets to call is silent by definition,
|
||
// and the tell was always meant to be the count beside its fan. The button
|
||
// is the table doing you the courtesy of putting a target on it.
|
||
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;
|
||
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;
|
||
}
|
||
|
||
// shownHand is the faces currently on screen. paintHand diffs against it so that
|
||
// only cards that are actually new fan in — without it, every redraw re-deals
|
||
// the whole hand in front of you, and a hand gets redrawn several times a lap.
|
||
var shownHand = [];
|
||
|
||
function key(c) { return c.color + "/" + c.value; }
|
||
|
||
// paintHand draws your fan. `live` is whether the cards are yours to click,
|
||
// which mid-script they are not: the game on screen is one the server has
|
||
// already moved past, and a card clicked during the lap would be a move against
|
||
// a board that no longer exists.
|
||
function paintHand(cards, playable, live) {
|
||
cards = cards || [];
|
||
// What was already on the table stays put. Matched by face and consumed, so a
|
||
// hand holding two red fives doesn't count one of them twice.
|
||
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 = !!live && !!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"; // it was already in your hand: don't deal it again
|
||
} 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, v.turn === 0 && v.phase !== "done");
|
||
}
|
||
|
||
// showHand redraws your fan from the hand the server stamped on the event that
|
||
// just changed it — see Event.Hand in the engine.
|
||
//
|
||
// This is the fix for a hand that used to sit there stale for the whole lap.
|
||
// bump() keeps the *bots'* fans honest and has always refused seat zero, so
|
||
// when a +4 landed on you at the top of a lap you watched four backs fly into
|
||
// your hand and then nothing: the cards themselves only turned up seconds
|
||
// later, when the script ended and paint() finally ran. You were looking at a
|
||
// hand you no longer held.
|
||
function showHand(cards) {
|
||
if (!cards) return;
|
||
paintHand(cards, {}, false);
|
||
// And the line above the fan counts what's in it, so it moves too.
|
||
countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
|
||
}
|
||
|
||
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");
|
||
// burst() is the room's win sound as well as its confetti; anything else is
|
||
// a night that didn't go your way.
|
||
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
|
||
else FX.sfx("lose");
|
||
}
|
||
|
||
// 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";
|
||
});
|
||
// Catching is free, but it is still a move: it can't go up while one of yours
|
||
// is in flight, and it can't go up on somebody else's turn.
|
||
seatsEl.querySelectorAll("[data-catch]").forEach(function (b) {
|
||
b.disabled = busy || !yours;
|
||
});
|
||
// 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();
|
||
hideGate();
|
||
|
||
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) {
|
||
// A seat sitting on one card it never called is the most valuable thing on
|
||
// the table, and it is worth more than whatever the phase was going to say.
|
||
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);
|
||
}
|
||
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. It lands
|
||
// with a slap — "card" for one going down on the pile, "deal" (softer, lower)
|
||
// for one coming off the deck into a hand, which is a card being *placed*.
|
||
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,
|
||
});
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Seat zero is not a bot and is not bumped — a fan of backs is no use to you,
|
||
// you want the faces. showHand does yours, from the hand the event carries.
|
||
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.
|
||
// The sound does the same job: a riffle, then cards landing, rather
|
||
// than twenty-eight separate slaps.
|
||
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);
|
||
// 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;
|
||
}
|
||
showHand(e.hand); // the card has landed; close the gap it left
|
||
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, sound: "deal" }));
|
||
}
|
||
var punished = e.kind === "forced";
|
||
if (punished) FX.sfx("bad");
|
||
// 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);
|
||
showHand(e.hand); // your cards, face up, as they land
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ---- the call, and the catch ---------------------------------------
|
||
//
|
||
// Somebody went down to one card without saying so, and somebody else
|
||
// noticed. Two cards off the deck, and the seat that took them wears the
|
||
// name of whoever spotted it — because "caught" with nobody attached is
|
||
// just a mystery.
|
||
case "caught": {
|
||
spotlight(e.seat);
|
||
var mine = e.seat === 0;
|
||
var caughtBy = (game && game.seats && game.seats[e.by]) || {};
|
||
var whom = mine
|
||
? "Caught! " + (caughtBy.name || "They") + " saw it"
|
||
: "Caught! +" + e.n;
|
||
// Catching a bot is a pointed finger; being caught is a thud.
|
||
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);
|
||
});
|
||
}
|
||
|
||
// You called a seat that had nothing to hide. Two cards, and they're
|
||
// yours: the price of the catch button not being a thing you just mash.
|
||
case "miscall": {
|
||
FX.sfx("bad");
|
||
var wrong = [];
|
||
for (var w = 0; w < e.n; w++) {
|
||
wrong.push(throwCard(back(), deckEl, handEl, { index: w, delay: w * 100, sound: "deal" }));
|
||
}
|
||
badge(0, "They called it. +" + e.n, "bad");
|
||
return Promise.all(wrong).then(function () {
|
||
showHand(e.hand);
|
||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||
return wait(520);
|
||
});
|
||
}
|
||
|
||
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);
|
||
FX.sfx("bad");
|
||
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 () {
|
||
showHand(e.hand);
|
||
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);
|
||
showHand(e.hand);
|
||
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);
|
||
showHand(e.hand); // if it was you, the hand goes with you
|
||
pending(0); // a dead seat pays no bill and leaves none behind
|
||
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":
|
||
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":
|
||
// 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");
|
||
FX.sfx("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; } // the colour first; the call comes after
|
||
offer(i, 0);
|
||
}
|
||
|
||
// offer is the last thing between a card and the pile. If playing it leaves you
|
||
// holding one card, the table wants a word out of you first.
|
||
//
|
||
// Which cards those are is the server's answer, not a count of your hand: No
|
||
// Mercy's "discard all" takes every card of its colour with it, so a six-card
|
||
// hand can land on one, and a browser subtracting one from six would let you
|
||
// walk into a catch it never warned you about.
|
||
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) {
|
||
// Restart the drain. The bar is a CSS animation and this element gets
|
||
// reused, so without kicking the browser between the two assignments it
|
||
// stays where the last one finished — a full bar that never moves, which
|
||
// is worse than no bar at all.
|
||
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);
|
||
}
|
||
|
||
// shut plays the card the gate was holding, with or without the word. Letting
|
||
// the clock run out is a real answer — it is the answer that gets you caught.
|
||
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 });
|
||
}
|
||
|
||
// hideGate drops the gate without playing anything. Only for the paths that tear
|
||
// the board down under it (an error, a resume) — a gate that outlived its game
|
||
// would play a card into the next one.
|
||
function hideGate() {
|
||
if (gateTimer) clearTimeout(gateTimer);
|
||
gateTimer = 0;
|
||
gate = null;
|
||
if (gateEl) gateEl.classList.add("hidden");
|
||
}
|
||
|
||
if (callBtn) callBtn.addEventListener("click", function () { shut(true); });
|
||
|
||
// Catching a seat that went quiet. It is not a turn — right or wrong, the turn
|
||
// stays with you — but it is a move, so it goes through the same door as one.
|
||
function catchSeat(seat) {
|
||
if (busy || !game || game.phase === "done" || game.turn !== 0) 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");
|
||
}
|
||
|
||
// 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();
|
||
offer(i, c); // named the colour; now, is it your last but one?
|
||
});
|
||
});
|
||
|
||
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();
|
||
});
|
||
})();
|