The last attempt built a card face out of text: a "♠" in a span for every pip. At the size a card actually is, a suit character renders as a speck — the shape is whatever font answered, it doesn't scale, and it can't be put on the half-row a real pip layout needs. The result read worse than the plain rank it replaced. So each face is one SVG on a 100×140 field, suits as vector shapes, pips at the coordinates a printed deck puts them. Courts get a framed panel with the suit above the letter and again below it upside down — mirroring a letter, which is what the first pass did, just stacks two of them into a blob; a real court mirrors a figure. Also restores .pete-card-back, which went out with the text rules it was sitting among: without it a face-down card had no back at all, so the dealer's hole card was invisible on the felt. Caught by driving a hand.
389 lines
15 KiB
JavaScript
389 lines
15 KiB
JavaScript
// 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;
|
||
}
|
||
|
||
// ---- 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 = {
|
||
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
|
||
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
|
||
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
|
||
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
|
||
'<path d="M44 60h12l7 36H37Z"/></g>',
|
||
};
|
||
|
||
// 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 '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
||
SUIT_ART[suit] + "</g>";
|
||
}
|
||
|
||
// 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 =
|
||
'<g>' +
|
||
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
||
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
||
"</g>";
|
||
return g + '<g transform="rotate(180 50 70)">' + 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 =
|
||
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
||
pipAt(face.suit, 50, 38, 0.95) +
|
||
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
||
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 =
|
||
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
||
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
|
||
}
|
||
|
||
// "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();
|
||
});
|
||
})();
|