// The blackjack table. // // The browser holds no game. It sends intents — deal, hit, stand, double — and // the server answers with the cards you're allowed to see plus the *script* of // how they got there: one event per card off the shoe, in the order the shoe // gave them up. This file's job is to play that script back at a human speed // rather than snapping the finished hand into place. // // Which is also why the hole card works the way it does: the server sends a // "dealer_hole" event with no card attached, because while you are still acting // it hasn't told anyone what that card is. It arrives with the reveal. (function () { "use strict"; var root = document.querySelector("[data-blackjack]"); if (!root) return; var dealerEl = root.querySelector("[data-dealer]"); var playerEl = root.querySelector("[data-player]"); var dTotalEl = root.querySelector("[data-dealer-total]"); var pTotalEl = root.querySelector("[data-player-total]"); var betChip = root.querySelector("[data-bet]"); var verdictEl = root.querySelector("[data-verdict]"); var betting = root.querySelector("[data-betting]"); var actions = root.querySelector("[data-actions]"); var betAmount = root.querySelector("[data-bet-amount]"); var dealBtn = root.querySelector("[data-deal]"); var msgEl = root.querySelector("[data-table-msg]"); var bet = 25; var busy = false; // a request is in flight, or cards are still landing var hand = null; // the hand as the server last described it var DEAL_MS = 380; // one card's flight, and the gap before the next var FLIP_MS = 450; var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; function pace(ms) { return reduced ? 0 : ms; } function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } function say(text, tone) { if (!text) { msgEl.classList.add("hidden"); return; } msgEl.textContent = text; msgEl.classList.remove("hidden"); msgEl.style.color = tone === "bad" ? "#cc3d4a" : ""; } // ---- drawing -------------------------------------------------------------- // cardEl builds one card. face === null means face-down: the card is dealt, // but this browser has not been told what it is. function cardEl(face) { var wrap = document.createElement("div"); wrap.className = "pete-card"; wrap.dataset.face = face ? "up" : "down"; // Every card flies out of the shoe, which sits in the top-right of the felt. // The offset is per-card, so a card landing further left flies further. wrap.style.setProperty("--deal-x", "14rem"); wrap.style.setProperty("--deal-y", "-6rem"); var inner = document.createElement("div"); inner.className = "pete-card-inner"; var front = document.createElement("div"); front.className = "pete-card-front"; var back = document.createElement("div"); back.className = "pete-card-back"; inner.appendChild(front); inner.appendChild(back); wrap.appendChild(inner); if (face) paintFace(front, face); return wrap; } // Pip layouts, the way a real deck lays them out — which is not "N suits in a // row". Each entry is [column, row] on a 3×7 grid: three columns, seven rows, // the same skeleton every printed deck has used for about four hundred years. // A pip below the halfway line is printed upside down, so it does that here. // // Sevens and tens have a pip that sits *between* two rows, so those span a pair // of rows and centre themselves across the pair. var PIPS = { "A": [[2, 4]], "2": [[2, 1], [2, 7]], "3": [[2, 1], [2, 4], [2, 7]], "4": [[1, 1], [3, 1], [1, 7], [3, 7]], "5": [[1, 1], [3, 1], [2, 4], [1, 7], [3, 7]], "6": [[1, 1], [3, 1], [1, 4], [3, 4], [1, 7], [3, 7]], "7": [[1, 1], [3, 1], [2, "2/4"], [1, 4], [3, 4], [1, 7], [3, 7]], "8": [[1, 1], [3, 1], [2, "2/4"], [1, 4], [3, 4], [2, "4/6"], [1, 7], [3, 7]], "9": [[1, 1], [3, 1], [1, 3], [3, 3], [2, 4], [1, 5], [3, 5], [1, 7], [3, 7]], "10": [[1, 1], [3, 1], [2, "2/4"], [1, 3], [3, 3], [1, 5], [3, 5], [2, "4/6"], [1, 7], [3, 7]], }; var COURT = { "J": "Jack", "Q": "Queen", "K": "King" }; function corner(face, where) { var el = document.createElement("span"); el.className = "pete-card-corner pete-card-corner-" + where; var r = document.createElement("span"); r.className = "pete-card-corner-rank"; r.textContent = face.rank; var s = document.createElement("span"); s.className = "pete-card-corner-suit"; s.textContent = face.suit; el.appendChild(r); el.appendChild(s); return el; } function pip(face, col, row) { var el = document.createElement("span"); el.className = "pete-card-pip"; el.textContent = face.suit; el.style.gridColumn = String(col); // A row given as "2/4" spans that pair and centres between them. el.style.gridRow = String(row); // The bottom half of a real card is printed the other way up. var mid = typeof row === "number" ? row > 4 : row === "4/6"; if (mid) el.dataset.flip = "1"; return el; } // paintFace draws an actual playing card: index in two opposite corners, and // either the pips or a court figure in the middle. The dealer's cards and // yours use the same face, because they came out of the same shoe. function paintFace(front, face) { front.dataset.red = face.red ? "1" : "0"; front.innerHTML = ""; front.appendChild(corner(face, "tl")); var body = document.createElement("span"); if (COURT[face.rank]) { // Court cards: the letter, big, with the suit sitting over each shoulder. // No portrait — a drawn face would fight the room, and this reads instantly. body.className = "pete-card-court"; var top = document.createElement("span"); top.className = "pete-card-court-suit"; top.textContent = face.suit; var letter = document.createElement("span"); letter.className = "pete-card-court-letter"; letter.textContent = face.rank; var bot = document.createElement("span"); bot.className = "pete-card-court-suit"; bot.dataset.flip = "1"; bot.textContent = face.suit; body.appendChild(top); body.appendChild(letter); body.appendChild(bot); } else { body.className = "pete-card-pips"; // An ace gets one big pip in the middle, the way an ace does. if (face.rank === "A") body.dataset.ace = "1"; var spots = PIPS[face.rank] || []; for (var i = 0; i < spots.length; i++) { body.appendChild(pip(face, spots[i][0], spots[i][1])); } } front.appendChild(body); front.appendChild(corner(face, "br")); front.setAttribute("aria-label", ariaFor(face)); } // "A♠" is not something a screen reader should be asked to pronounce. function ariaFor(face) { var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" }; var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank); var suit = SUITS[face.suit]; return suit ? name + " of " + suit : face.label; } // turnOver flips a face-down card up, now that we've been told what it is. function turnOver(wrap, face) { if (!wrap) return; paintFace(wrap.querySelector(".pete-card-front"), face); wrap.dataset.face = "up"; } function totals(v) { if (v.total) { pTotalEl.textContent = v.total + (v.soft ? " (soft)" : ""); pTotalEl.classList.remove("hidden"); } else { pTotalEl.classList.add("hidden"); } // While the hole card is down, the dealer's total is only what's showing — // so say so, rather than printing a number that quietly means something else. if (v.dealer && v.dealer.length) { dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total); dTotalEl.classList.remove("hidden"); } else { dTotalEl.classList.add("hidden"); } } // paint puts a hand on the felt with no animation. This is the resume path: // you reloaded, or Pete restarted, and your cards are simply there. function paint(v) { dealerEl.innerHTML = ""; playerEl.innerHTML = ""; if (!v) { setPhase(null); return; } v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); }); v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); }); if (v.hole) dealerEl.appendChild(cardEl(null)); totals(v); setPhase(v); } var VERDICTS = { blackjack: "Blackjack! 🎉", win: "You win!", dealer_bust: "Dealer busts. You win!", lose: "Dealer takes it.", bust: "Bust.", push: "Push — your bet comes back.", }; function verdict(v) { var text = VERDICTS[v.outcome] || ""; 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"); playerEl.dataset.won = v.net > 0 ? "1" : "0"; } // setPhase swaps the controls: bet between hands, act during one. function setPhase(v) { hand = v; var live = !!v && v.phase === "player"; betting.classList.toggle("hidden", live); actions.classList.toggle("hidden", !live); if (v && v.bet) { betChip.textContent = "Bet " + v.bet.toLocaleString(); betChip.classList.remove("hidden"); } else { betChip.classList.add("hidden"); } if (live) { var dbl = actions.querySelector('[data-move="double"]'); if (dbl) dbl.disabled = !v.can_double; } if (!v || v.phase !== "player") verdictEl.classList.toggle("hidden", !(v && v.outcome)); } // ---- the script ----------------------------------------------------------- // play walks the server's events, one card at a time. It is deliberately the // only thing that renders during a hand: the final state is applied at the end, // so what you watch and what the server says can't disagree halfway through. function play(view) { var events = view.events || []; var final = view.hand; var hole = null; // the face-down card element, once one has been dealt var chain = Promise.resolve(); events.forEach(function (e) { chain = chain.then(function () { switch (e.kind) { case "deal": dealerEl.innerHTML = ""; playerEl.innerHTML = ""; playerEl.dataset.won = "0"; verdictEl.classList.add("hidden"); return; case "player_card": playerEl.appendChild(cardEl(e.card)); return wait(DEAL_MS); case "dealer_card": dealerEl.appendChild(cardEl(e.card)); return wait(DEAL_MS); case "dealer_hole": hole = cardEl(null); dealerEl.appendChild(hole); return wait(DEAL_MS); case "reveal": // The hole card turns over. Its face is in the final hand — this is // the first moment the server has been willing to say what it was. if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]'); if (hole && final && final.dealer && final.dealer[1]) { turnOver(hole, final.dealer[1]); } return wait(FLIP_MS); case "settle": return; } }); }); return chain.then(function () { if (final) { totals(final); setPhase(final); if (final.phase === "done") verdict(final); } else { paint(null); } }); } // ---- talking to the table ------------------------------------------------- function send(path, body) { if (busy) return; busy = true; say(""); return window.PeteGames.post(path, body) .then(function (view) { window.PeteGames.apply(view); return play(view); }) .catch(function (err) { say(err.message, "bad"); // Whatever we thought was on the felt, the server is the authority on it. return window.PeteGames.refresh(); }) .then(function () { busy = false; }); } // ---- betting -------------------------------------------------------------- function showBet() { betAmount.textContent = bet.toLocaleString(); var money = window.PeteGames.view(); if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet; } root.querySelectorAll("[data-chip]").forEach(function (btn) { btn.addEventListener("click", function () { bet += parseInt(btn.dataset.chip, 10); showBet(); }); }); var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { bet = 0; showBet(); }); } if (dealBtn) { dealBtn.addEventListener("click", function () { if (bet <= 0) { say("Put something on it first.", "bad"); return; } send("/api/games/blackjack/deal", { bet: bet }); }); } root.querySelectorAll("[data-move]").forEach(function (btn) { btn.addEventListener("click", function () { send("/api/games/blackjack/move", { move: btn.dataset.move }); }); }); document.addEventListener("keydown", function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return; if (!hand || hand.phase !== "player" || busy) return; var move = { h: "hit", s: "stand", d: "double" }[e.key.toLowerCase()]; if (!move) return; if (move === "double" && !hand.can_double) return; e.preventDefault(); send("/api/games/blackjack/move", { move: move }); }); // The money bar owns the first fetch; the table picks up whatever it found, // including a hand left sitting on the felt by a reload or a redeploy. var resumed = false; window.PeteGames.onUpdate(function (v) { if (!resumed) { resumed = true; if (v.hand) paint(v.hand); if (v.hand && v.hand.phase === "done") verdict(v.hand); } showBet(); }); })();