games: the money moves
The table dealt cards but settled money by editing a number. So the felt got the two things it was missing: a bet spot in front of you, and the house's rack beside the shoe. Every chip is now always travelling between one of those and the other. You build a bet by throwing chips onto the spot — the chip you clicked is the chip that flies. The stake sits there through the hand. The house pays out of its rack into the spot, and the pile is then swept back to your stack. A loss goes to the rack and does not come back. Two rules hold it together. The number under the pile is a readout of the pile, never the other way round: the bet starts at nothing rather than at a default nobody put down, and a settled hand leaves your stake back up on the spot, because otherwise the panel prints "your bet: 300" over an empty circle. And the chip bar does not move until the chips that justify it have landed — a counter that pays you before the dealer turns over is a counter that has told you the ending. casino-fx.js is the engine underneath: chips fly on an arc, out of a fixed overlay so no container clips one crossing from a button to the felt. It knows nothing about blackjack. Also: cards land with weight and a degree or two of tilt, so a hand looks dealt rather than typeset; the dealer takes a beat before drawing out; and a natural gets confetti, which is the only thing in the room that does. Driven in a real browser, which is the only way to review an animation — and which is what caught the verdict pill rendering white on white in a dark room, a chip rack sitting on top of the dealer, and Hit being offered over a table that was still being paid out. devcasino_test.go is that harness, kept.
This commit is contained in:
@@ -9,17 +9,26 @@
|
||||
// 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 betChip = root.querySelector("[data-bet]");
|
||||
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]");
|
||||
@@ -27,14 +36,27 @@
|
||||
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
|
||||
// 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 = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
|
||||
@@ -47,6 +69,8 @@
|
||||
|
||||
// ---- 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) {
|
||||
@@ -57,6 +81,10 @@
|
||||
// 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";
|
||||
@@ -180,6 +208,87 @@
|
||||
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)" : "");
|
||||
@@ -198,15 +307,17 @@
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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); return; }
|
||||
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);
|
||||
}
|
||||
@@ -227,7 +338,11 @@
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
playerEl.dataset.won = v.net > 0 ? "1" : "0";
|
||||
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.
|
||||
@@ -237,12 +352,6 @@
|
||||
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;
|
||||
@@ -255,11 +364,32 @@
|
||||
// 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) {
|
||||
//
|
||||
// `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 () {
|
||||
@@ -276,8 +406,15 @@
|
||||
return wait(DEAL_MS);
|
||||
|
||||
case "dealer_card":
|
||||
dealerEl.appendChild(cardEl(e.card));
|
||||
return wait(DEAL_MS);
|
||||
// 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);
|
||||
@@ -300,13 +437,50 @@
|
||||
});
|
||||
|
||||
return chain.then(function () {
|
||||
if (final) {
|
||||
totals(final);
|
||||
setPhase(final);
|
||||
if (final.phase === "done") verdict(final);
|
||||
} else {
|
||||
paint(null);
|
||||
}
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,18 +492,24 @@
|
||||
say("");
|
||||
return window.PeteGames.post(path, body)
|
||||
.then(function (view) {
|
||||
window.PeteGames.apply(view);
|
||||
return play(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();
|
||||
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();
|
||||
@@ -339,14 +519,37 @@
|
||||
|
||||
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
bet += parseInt(btn.dataset.chip, 10);
|
||||
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 () { bet = 0; showBet(); });
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user