The trivia ladder handled a walk before it looked at the clock, so the timeout only ever bit if the browser volunteered it. Sit on a question, look it up, answer if you find it and walk if you don't, and you never lose a ladder. The clock is now the first thing that happens to a move. The house's chip rack was wired up as bet buttons on blackjack and hangman: it's four spans with data-chip on them and nothing said the handler only wanted the real ones. Clicking the house's money raised your bet. Hangman had two definitions of "a letter you'd guess" — unicode in the engine, ASCII in the renderer — and a phrase with an accent in it would have had no tile to fill and no key to fill it with. One definition now. Plus: trivia's countdown no longer freezes at zero when the server turns down a timeout report it was early for, questions whose wrong answer decodes into the right one are dropped at the door, and hangman bets on PeteFX's spot like every other table instead of its own copy of it.
415 lines
16 KiB
JavaScript
415 lines
16 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.
|
|
//
|
|
// 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]");
|
|
|
|
// The spot owns the chips on the felt and the number under them — see PeteFX.
|
|
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
|
|
// at a default stake nobody put down.
|
|
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
|
|
|
|
var bet = 0; // what you're building between hands
|
|
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 --------------------------------------------------------------
|
|
//
|
|
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
|
|
// every other table in the room. Here a card is always dealt out of the shoe
|
|
// and always lands with a degree or two of tilt on it, which are this table's
|
|
// two opinions about a card and the only ones it has.
|
|
|
|
var CARDS = window.PeteCards;
|
|
|
|
function cardEl(face) { return CARDS.el(face); }
|
|
var turnOver = CARDS.turnOver;
|
|
|
|
// ---- the money on the felt -------------------------------------------------
|
|
|
|
// 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 spot.pour(from || purseEl, 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.
|
|
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
|
}
|
|
|
|
// The house pays first, into the spot beside your stake, so you watch the
|
|
// winnings arrive on top of the bet that earned them.
|
|
return spot
|
|
.pour(houseEl, back, { gap: 60 })
|
|
.then(function () { return wait(back > 0 ? 380 : 200); })
|
|
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
|
// does the number in the bar move.
|
|
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
|
}
|
|
|
|
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); spot.render(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));
|
|
spot.render(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 > spot.amount) {
|
|
var extra = final.bet - spot.amount;
|
|
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) spot.render(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;
|
|
}
|
|
|
|
// Scoped to buttons: the bare [data-chip] spans in the corner are the house's
|
|
// rack, 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();
|
|
|
|
// 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 the spot's total 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;
|
|
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 || !spot.amount) { bet = 0; showBet(); return; }
|
|
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
|
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();
|
|
});
|
|
})();
|