// 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. // // The money is animated on the same principle. Every number the server sends is // also a movement: a stake is chips leaving your pile and landing on the spot, a // payout is chips coming out of the house's rack, a loss is your stack being // taken away. Nothing about the money changes on this table without something // crossing the felt to make it change — including the count in the chip bar, // which is deliberately not updated until the chips that justify it have landed. (function () { "use strict"; var root = document.querySelector("[data-blackjack]"); if (!root) return; var FX = window.PeteFX; 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 dLabelEl = root.querySelector("[data-dealer-label]"); 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]"); // The three places a chip can be: your pile (in the bar above), the spot in // front of you, and the house's rack on the felt. var purseEl = document.querySelector("[data-chips]"); var spotEl = root.querySelector("[data-spot]"); var stackEl = root.querySelector("[data-stack]"); var spotTotalEl = root.querySelector("[data-spot-total]"); var houseEl = root.querySelector("[data-house]"); // Nothing is bet until a chip is on the felt. The number in the panel is a // readout of the pile, so it starts where the pile does — at nothing — rather // than at a default stake nobody put down. var bet = 0; // what you're building between hands var staked = 0; // what is actually sitting on the spot right now 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 BEAT_MS = 600; // the dealer thinking before they draw out 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) { if (!text) { msgEl.classList.add("hidden"); return; } msgEl.textContent = text; msgEl.classList.remove("hidden"); msgEl.style.color = tone === "bad" ? "#cc3d4a" : ""; } // ---- drawing -------------------------------------------------------------- var dealt = 0; // how many cards this table has put down, ever — the tilt seed // 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"); // Where it comes to rest. A degree or two either way is the whole difference // between cards that were dealt onto a table and cards that were laid out in // a grid, and it costs one custom property. wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg"); 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; } // ---- the face ------------------------------------------------------------ // // A card is drawn, not typed. The first attempt set the pips as text — "♠" in a // span — and at the size a card actually is, a suit character renders as a // speck: the shape is whatever font happened to answer, it doesn't scale, and // it can't be positioned to the half-row a real pip layout needs. // // So each face is one SVG on a 100×140 field (the proportions of a real card), // with the suits as vector shapes. Everything below is coordinates on that // field, which is why the pips land where a printed deck puts them instead of // where a flexbox felt like putting them. var SUIT_ART = { "♠": '', "♥": '', "♦": '', "♣": '' + '', }; // Pip layouts, the way a real deck lays them out — which is not "N suits in a // row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27, // 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of // them, which is the whole reason this is a table of coordinates and not a // grid. Anything below the middle is printed upside down, so it is. var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle var L = 30, C = 50, Rr = 70; // the three columns var PIPS = { "A": [[C, 70, 2.1]], "2": [[C, R[1]], [C, R[7]]], "3": [[C, R[1]], [C, R[4]], [C, R[7]]], "4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]], "5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]], "6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]], "7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]], "8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]], "9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]], "10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]], }; var COURT = { "J": "Jack", "Q": "Queen", "K": "King" }; // One pip: the suit art, scaled and dropped at [x, y], turned over if it sits // below the middle of the card. function pipAt(suit, x, y, scale) { var s = (scale || 1) * 0.17; var turn = y > 70 ? " rotate(180 50 50)" : ""; return '' + SUIT_ART[suit] + ""; } // The corner index: rank over suit. Printed in both corners, the second one // upside down, which is what lets you read a card from a fanned hand. function index(face) { var g = '' + '' + face.rank + "" + '' + SUIT_ART[face.suit] + "" + ""; return g + '' + g + ""; } // paintFace draws the card. 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"; var body = ""; if (COURT[face.rank]) { // Court cards: a framed panel, the suit above the letter and again below it // the other way up. A real court mirrors a *figure*; mirroring a letter just // stacks two of them into a blob, which is exactly what the first attempt // did. No portrait either — a drawn king would fight the room, and this // reads instantly at the size a card actually is. body = '' + pipAt(face.suit, 50, 38, 0.95) + '' + face.rank + "" + pipAt(face.suit, 50, 102, 0.95); } else { var spots = PIPS[face.rank] || []; for (var i = 0; i < spots.length; i++) { body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]); } } front.innerHTML = '' + index(face) + body + ""; } // "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"; } // ---- the money on the felt ------------------------------------------------- // // `staked` is what the spot is holding. Every path that changes it also moves // chips to say so, so the two can't come apart: renderStack draws the pile, // and the fly* calls are what put it there. function renderStack(amount) { staked = amount || 0; stackEl.innerHTML = ""; spotEl.dataset.live = staked > 0 ? "1" : "0"; if (!staked) { spotTotalEl.classList.add("hidden"); return; } FX.chipsFor(staked).forEach(function (d, i) { var c = FX.disc(d); c.style.setProperty("--i", i); c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg"); c.style.animationDelay = pace(i * 40) + "ms"; stackEl.appendChild(c); }); spotTotalEl.textContent = staked.toLocaleString(); spotTotalEl.classList.remove("hidden"); } // pour throws a run of chips from one place to another and grows the pile on // the spot as each one lands — by the value of the chip that landed, so the // total under the pile counts up the way the chips do. The last chip carries // the remainder, because chipsFor caps how many chips it will make you watch // and the pile still has to end on the real number. function pour(from, to, amount, opts) { if (amount <= 0) return Promise.resolve(); var base = staked; var chips = FX.chipsFor(amount, 8); var run = 0; return FX.flyMany(from, to, chips, Object.assign({ onLand: function (d, i) { run += d; renderStack(base + (i === chips.length - 1 ? amount : run)); }, }, opts || {})); } // stake moves chips from your pile onto the spot: the bet you build before a // deal, and the second bet a double puts down beside it. function stake(amount, from) { return pour(from || purseEl, spotEl, amount); } // settleChips is what the felt does about the outcome, after the cards have // finished telling you what it is. It reads the same two numbers the ledger // moved: `bet` (already off your pile since the deal) and `payout` (what comes // back — stake plus winnings less rake, or nothing at all). function settleChips(final) { var payout = final.payout || 0; var back = payout - final.bet; // what the house is adding, if anything if (payout <= 0) { // The house takes it. The stack goes to the rack and doesn't come back. var lost = FX.chipsFor(final.bet, 8); var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true }); renderStack(0); return chain; } // The house pays first, into the spot beside your stake, so you watch the // winnings arrive on top of the bet that earned them. var pay = pour(houseEl, spotEl, back, { gap: 60 }); // Paid, then swept up: the whole lot comes back to your pile, and only then // does the number in the bar move. return pay .then(function () { return wait(back > 0 ? 380 : 200); }) .then(function () { var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 }); renderStack(0); return home; }); } 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 — including // the stake, which is still on the spot because the server still has it. function paint(v) { dealerEl.innerHTML = ""; playerEl.innerHTML = ""; if (!v) { setPhase(null); renderStack(0); 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)); renderStack(v.phase === "done" ? 0 : v.bet); 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" : v.net < 0 ? "-1" : "0"; // The one thing in this room that gets confetti. A natural is rare, it pays // 3:2, and if everything celebrated then nothing would. if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 }); } // 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 (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. // // `money` is the one exception, and it's a deliberate one. On a hand that is // still running, the chip bar is right immediately — your stake left your pile // when you pressed Deal, and it's sitting on the spot where you can see it. On // a hand that *settles*, the bar is left alone until the chips have physically // come home, because a counter that pays you before the dealer has turned over // is a counter that has told you the ending. function play(view, money) { var events = view.events || []; var final = view.hand; var settles = !!final && final.phase === "done"; var hole = null; // the face-down card element, once one has been dealt var chain = Promise.resolve(); var drew = false; // has the dealer drawn since the reveal? if (!settles) money(); // Whatever the server says the stake is, that's what has to be on the spot. // Two things get here: a double, which puts a second bet down beside the // first, and a deal whose bet was typed rather than stacked (you kept last // hand's number and just pressed Deal). Either way the chips go down before // the card they're buying does. if (final && final.bet > staked) { var extra = final.bet - staked; chain = chain.then(function () { return stake(extra); }); } 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": // The dealer takes a moment before the first card they draw out. // Card, card, card with no breath in between is a machine dealing; // the pause is the only thing on this table that plays as suspense. var beat = drew ? Promise.resolve() : think(); drew = true; return beat.then(function () { 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) { paint(null); money(); return; } totals(final); if (!settles) { setPhase(final); return; } // The hand is over: nothing is on offer while the money is moving. Hit and // Stand go now, and Deal comes back at the far end. actions.classList.add("hidden"); verdict(final); // The chips move, and the bar catches up with them when they arrive. The // betting controls come back last, once the felt is clear: offering Deal // over a table that is still being paid out invites a click the table then // has to refuse. return settleChips(final) .then(money) .then(function () { return standing(final.bet); }) .then(function () { setPhase(final); }); }); } // standing leaves your bet up for the next hand, the way you would at a table: // the stake that just settled goes straight back on the spot. It costs nothing // — chips on the spot are a proposal until you press Deal — and it's what keeps // the number in the panel honest, because otherwise a settled hand leaves // "your bet: 300" printed over an empty spot. function standing(amount) { var money = window.PeteGames.view(); if (!amount || !money || money.chips < amount) { bet = 0; showBet(); return; } bet = amount; showBet(); return stake(amount); } // think is the dealer's beat: a pause with something to look at, so it reads as // deliberation rather than as the page having hung. function think() { if (reduced || !dLabelEl) return wait(0); dLabelEl.classList.add("pete-dealer-think"); return wait(BEAT_MS).then(function () { dLabelEl.classList.remove("pete-dealer-think"); }); } // ---- talking to the table ------------------------------------------------- function send(path, body) { if (busy) return; busy = true; say(""); return window.PeteGames.post(path, body) .then(function (view) { // play() decides *when* the money lands; see the note on it. return play(view, function () { window.PeteGames.apply(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 (v) { if (v && !v.hand) renderStack(0); }); }) .then(function () { busy = false; }); } // ---- betting -------------------------------------------------------------- // // A bet is built by putting chips on the spot, one at a time, and it is those // chips the deal then rides on — the number under the pile is a readout of the // pile, not the other way round. 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 () { 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(); // The chip you clicked is the chip that flies: same colour, same size, off // the button and onto the felt. The pile only grows once it gets there — // but `staked` moves now, so a Deal pressed mid-flight still knows the chip // is on its way and doesn't put a second one down. var target = bet; staked = bet; FX.fly(btn, spotEl, { denom: d }).then(function () { if (bet >= target) renderStack(target); // unless Clear got there first }); }); }); var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { if (busy || !staked) { bet = 0; showBet(); return; } FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 }); bet = 0; renderStack(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(); }); })();