A code review of the uno table found the stuck guard had never once fired. It counted how many bots had passed in a row and wanted more of them than there are seats — but the bot loop hands the turn back the moment it comes round to you, so the count could never get there, and your own empty-handed pass was never in it. A dead table just passed the turn round forever. That is not an ugly ending, it's a game you cannot finish, and a game you cannot finish is chips you cannot cash out. So it asks the real question now: is there anything to draw, and is anyone holding a card that goes. And the table let go of itself too early. busy came off when the request landed, not when the script it came back with had finished playing — so for the seconds a bot lap takes, you could click a card at a board the server had already moved past. It comes off at the end now, like the other tables. Also: left: 0 was being dropped on its way out the door, which is the one number that matters (the seat that just went out), the deck counter didn't come back after a reshuffle, and hoisting fly() into flyNode() had quietly flattened the chip arc on every other table in the room. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
708 lines
25 KiB
JavaScript
708 lines
25 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 betting = root.querySelector("[data-betting]");
|
||
var playing = root.querySelector("[data-playing]");
|
||
var drawBtn = root.querySelector("[data-draw]");
|
||
var passBtn = root.querySelector("[data-pass]");
|
||
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 -----------------------------------------------------
|
||
|
||
// GLYPHS are what goes in the middle of an action card. The face the engine
|
||
// sends is a word ("skip", "+2"); this is the drawing of it.
|
||
var GLYPHS = { skip: "🚫", reverse: "⇄", "+2": "+2", wild: "★", "+4": "+4" };
|
||
|
||
// 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 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";
|
||
oval.textContent = GLYPHS[c.value] || c.value;
|
||
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 = GLYPHS[c.value] || c.value;
|
||
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;
|
||
}
|
||
|
||
el.appendChild(face);
|
||
el.setAttribute("aria-label", label(c));
|
||
return el;
|
||
}
|
||
|
||
function label(c) {
|
||
var v = c.value === "+2" ? "draw two" :
|
||
c.value === "+4" ? "wild draw four" :
|
||
c.value === "wild" ? "wild" : c.value;
|
||
if (c.wild) return v + (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";
|
||
|
||
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.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;
|
||
}
|
||
|
||
var feltEl = root.querySelector(".pete-uno");
|
||
|
||
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";
|
||
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.";
|
||
}
|
||
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";
|
||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||
});
|
||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn;
|
||
if (passBtn) passBtn.disabled = busy || !yours;
|
||
if (deckEl) deckEl.disabled = busy || !yours || drawn;
|
||
}
|
||
|
||
function setPhase(v) {
|
||
game = v;
|
||
var live = !!v && v.phase !== "done";
|
||
var drawn = live && v.phase === "drawn";
|
||
betting.classList.toggle("hidden", live);
|
||
playing.classList.toggle("hidden", !live);
|
||
hideWild();
|
||
|
||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn);
|
||
if (passBtn) passBtn.classList.toggle("hidden", !drawn);
|
||
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]");
|
||
if (count) count.textContent = 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";
|
||
if (punished) 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 "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" });
|
||
}
|
||
|
||
// ---- 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 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 });
|
||
});
|
||
}
|
||
|
||
pickTier(tier);
|
||
|
||
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();
|
||
});
|
||
})();
|