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:
prosolis
2026-07-14 00:33:49 -07:00
parent b00da21a47
commit 6961f90634
8 changed files with 845 additions and 64 deletions

View File

@@ -653,40 +653,235 @@ html[data-phase="night"] {
}
/* The flight itself: out of the shoe (up and to the right), scaled down and
spinning slightly, into place. */
spinning slightly, into place — and then a beat of settling, because a card
that stops dead on the felt looks like it was pasted there. It overshoots a
little and rocks back onto --tilt, the small resting angle the JS gives each
card so a hand looks handled rather than typeset. */
@keyframes pete-deal {
from {
0% {
opacity: 0;
transform: translate(var(--deal-x, 14rem), var(--deal-y, -7rem)) scale(0.72) rotate(9deg);
}
to {
62% {
opacity: 1;
transform: translate(0, 0) scale(1) rotate(0);
transform: translate(0, 0.35rem) scale(1.05) rotate(calc(var(--tilt, 0deg) - 2deg));
}
100% {
opacity: 1;
transform: translate(0, 0) scale(1) rotate(var(--tilt, 0deg));
}
}
/* And the weight: the felt takes the hit. The shadow under a landing card
spreads and darkens for the instant it arrives. */
.pete-card::after {
content: "";
position: absolute;
inset: auto 6% -0.35rem 6%;
height: 0.6rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
filter: blur(5px);
animation: pete-thud 0.42s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
@keyframes pete-thud {
0%, 55% { opacity: 0; transform: scale(0.4); }
72% { opacity: 0.9; transform: scale(1.15); }
100% { opacity: 0.45; transform: scale(1); }
}
/* A settled hand: the winning side gets a little lift, so a win reads at a
glance rather than only in the text. */
.pete-hand[data-won="1"] .pete-card { animation: pete-deal 0.42s cubic-bezier(0.22,1,0.36,1) backwards, pete-win 0.6s ease 0.1s; }
@keyframes pete-win {
0%, 100% { transform: translateY(0); }
40% { transform: translateY(-0.6rem); }
0%, 100% { transform: rotate(var(--tilt, 0deg)) translateY(0); }
40% { transform: rotate(var(--tilt, 0deg)) translateY(-0.6rem); }
}
/* A losing hand doesn't just sit there being wrong: it greys off. */
.pete-hand[data-won="-1"] .pete-card {
filter: saturate(0.45) brightness(0.78);
transition: filter 0.5s ease 0.2s;
}
/* Chips: the denominations you bet with. */
.pete-chip {
background: radial-gradient(circle at 50% 35%, rgba(255,255,255,0.28), transparent 60%), var(--chip, #e07a5f);
border: 3px dashed rgba(255,255,255,0.55);
box-shadow: 0 3px 0 rgba(60,40,20,0.22), 0 6px 14px rgba(60,40,20,0.18);
/* ---- chips ---------------------------------------------------------------
One disc, three jobs: the buttons you bet with, the stack sitting in the bet
spot, and the chip in mid-air between them. Same face on all three, because
they are meant to read as the *same chip* being moved around. */
.pete-disc {
position: relative;
border-radius: 999px;
background:
radial-gradient(circle at 50% 30%, rgba(255,255,255,0.30), transparent 62%),
var(--chip, #e07a5f);
border: 2px solid rgba(0,0,0,0.22);
box-shadow: 0 3px 0 rgba(0,0,0,0.28), 0 6px 14px rgba(0,0,0,0.20);
}
/* The edge spots every casino chip has, cut as a dashed ring inside the rim. */
.pete-disc::before {
content: "";
position: absolute;
inset: 11%;
border-radius: 999px;
border: 2px dashed rgba(255,255,255,0.55);
}
.pete-disc[data-chip="5"] { --chip: #5aa9e6; }
.pete-disc[data-chip="25"] { --chip: #4caf7d; }
.pete-disc[data-chip="100"] { --chip: #2b2118; }
.pete-disc[data-chip="500"] { --chip: #b079d6; }
.pete-chip { /* the buttons */
transition: transform 0.12s ease;
}
.pete-chip > span {
position: relative; /* over the dashed ring */
}
.pete-chip:active { transform: translateY(1px) scale(0.94); }
/* The bet spot: where your stake actually sits while the hand is played. */
.pete-spot {
position: relative;
display: grid;
place-items: center;
height: 7rem;
width: 7rem;
flex: none;
border-radius: 999px;
border: 2px dashed rgba(255,255,255,0.35);
background: radial-gradient(circle at 50% 40%, rgba(255,255,255,0.10), transparent 70%);
box-shadow: inset 0 0 0 6px rgba(0,0,0,0.08);
transition: box-shadow 0.3s ease, border-color 0.3s ease;
}
.pete-spot[data-live="1"] {
border-color: rgba(var(--glow, 242,181,61), 0.85);
box-shadow: inset 0 0 0 6px rgba(0,0,0,0.08), 0 0 22px rgba(var(--glow, 242,181,61), 0.35);
}
.pete-spot-label {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255,255,255,0.35);
}
.pete-spot[data-live="1"] .pete-spot-label { opacity: 0; }
/* The stack in the spot. Each chip is offset up the pile by its index, so ten
chips look like ten chips rather than one chip with a number on it. */
.pete-stack {
position: absolute;
inset: 0;
pointer-events: none;
}
.pete-stack .pete-disc {
position: absolute;
left: 50%;
top: 50%;
height: 2.6rem;
width: 2.6rem;
margin: -1.3rem 0 0 -1.3rem;
/* Each chip sits proud of the one below it by more than its own rim, so a
stack of three reads as three. Less than this and they hide inside each
other and a 150 bet looks the same as a 25 one. */
transform: translateY(calc(-0.5rem * var(--i, 0))) rotate(var(--spin, 0deg));
box-shadow: 0 2px 0 rgba(0,0,0,0.35), 0 4px 8px rgba(0,0,0,0.25);
animation: pete-chip-land 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}
@keyframes pete-chip-land {
from { transform: translateY(calc(-0.5rem * var(--i, 0) - 1.1rem)) rotate(var(--spin, 0deg)) scale(1.18); }
}
.pete-spot-total {
position: absolute;
bottom: -0.6rem;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
border-radius: 999px;
background: rgba(0,0,0,0.45);
padding: 0.1rem 0.6rem;
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff;
}
/* A chip in mid-air. Lives in a fixed overlay so it can cross from the betting
buttons, over the page, and onto the felt without any container clipping it. */
.pete-fly-layer {
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
overflow: hidden;
}
.pete-fly {
position: absolute;
height: 2.6rem;
width: 2.6rem;
margin: -1.3rem 0 0 -1.3rem;
will-change: transform;
}
/* The house's own chips, racked on the felt. This is where a payout comes
from, and where a losing stake goes — so the money has somewhere to be.
It sits alongside the shoe rather than over on the left, because the left of
the felt is where the dealer's cards land. */
.pete-rack {
position: absolute;
top: 1.25rem;
right: 5.75rem;
display: flex;
align-items: flex-end;
gap: 0.4rem;
padding: 0.5rem 0.6rem 0.45rem;
border-radius: 0.75rem;
background: rgba(0,0,0,0.20);
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.06);
}
/* A column in the rack is a *stack*, so it's striped: one band per chip, with
the shadow of the chip above it cut into each. A plain coloured lozenge here
read as a blob, which is the one thing a pile of money shouldn't. */
.pete-rack span {
display: block;
width: 1.7rem;
border-radius: 0.35rem;
background:
linear-gradient(90deg, rgba(0,0,0,0.28), transparent 35%, transparent 65%, rgba(0,0,0,0.28)),
repeating-linear-gradient(180deg,
rgba(255,255,255,0.18) 0 0.06rem,
var(--chip, #e07a5f) 0.06rem 0.3rem,
rgba(0,0,0,0.35) 0.3rem 0.36rem);
border: 1px solid rgba(0,0,0,0.35);
height: calc(0.36rem * var(--stack, 3));
}
.pete-rack span[data-chip="5"] { --chip: #5aa9e6; }
.pete-rack span[data-chip="25"] { --chip: #4caf7d; }
.pete-rack span[data-chip="100"] { --chip: #2b2118; }
.pete-rack span[data-chip="500"] { --chip: #b079d6; }
/* The burst. A natural gets confetti; nothing else in the room does, which is
what keeps it worth something. */
.pete-spark {
position: absolute;
height: 0.75rem;
width: 0.45rem;
border-radius: 2px;
box-shadow: 0 1px 3px rgba(0,0,0,0.35);
will-change: transform, opacity;
}
/* The dealer's beat before they draw out. */
.pete-dealer-think {
animation: pete-think 0.9s ease-in-out infinite;
}
@keyframes pete-think {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; transform: translateY(-1px); }
}
.pete-chip[data-chip="5"] { --chip: #5aa9e6; }
.pete-chip[data-chip="25"] { --chip: #4caf7d; }
.pete-chip[data-chip="100"] { --chip: #2b2118; }
.pete-chip[data-chip="500"] { --chip: #b079d6; }
.pete-chip[aria-pressed="true"] { outline: 3px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.pete-card { animation: none; }
.pete-card,
.pete-card::after,
.pete-stack .pete-disc,
.pete-dealer-think { animation: none; }
.pete-card-inner { transition: none; }
.pete-hand[data-won="1"] .pete-card { animation: none; }
}

File diff suppressed because one or more lines are too long

View File

@@ -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) {

View File

@@ -0,0 +1,249 @@
// The moving parts of the room.
//
// A casino is mostly one gesture repeated: something of value is picked up here
// and put down there, in front of you, slowly enough that you can object. So the
// only real idea in this file is fly() — take an element's place on screen, take
// another's, and move a chip between them on an arc that a hand would make.
//
// Everything flies in a fixed overlay pinned to the viewport, not inside the
// felt, because a chip's journey starts on a button *outside* the felt and any
// container in between would clip it halfway. Coordinates come from
// getBoundingClientRect, so the overlay needs no knowledge of the layout at all
// and nothing has to be positioned relative to anything else.
//
// Exposed as window.PeteFX. Nothing in here knows what blackjack is.
(function () {
"use strict";
var reduced =
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var layer = null;
function stage() {
if (!layer) {
layer = document.createElement("div");
layer.className = "pete-fly-layer";
layer.setAttribute("aria-hidden", "true");
document.body.appendChild(layer);
}
return layer;
}
// Where a thing is, in viewport coordinates: the middle of it.
function centre(target) {
if (!target) return { x: 0, y: 0 };
if (typeof target.x === "number") return target;
var r = target.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
// DENOMS, biggest first — the order you break an amount down in.
var DENOMS = [500, 100, 25, 5];
// chipsFor turns an amount into the chips a dealer would actually push across:
// as few as possible, biggest first. Capped, because a €10,000 bet is 20 purple
// chips and nobody wants to watch 20 of anything fly. Past the cap the stack
// just gets shorter than the number under it, which is what a real tall stack
// looks like from across a table anyway.
function chipsFor(amount, cap) {
var out = [];
var left = Math.max(0, Math.round(amount || 0));
for (var i = 0; i < DENOMS.length && left > 0; i++) {
while (left >= DENOMS[i]) {
out.push(DENOMS[i]);
left -= DENOMS[i];
}
}
if (left > 0) out.push(5); // a remainder below the smallest chip still shows up
var max = cap || 12;
return out.length > max ? out.slice(0, max) : out;
}
function disc(denom) {
var el = document.createElement("div");
el.className = "pete-disc";
el.dataset.chip = String(denom);
return el;
}
// A deterministic wobble per index, so a stack looks hand-placed but doesn't
// reshuffle itself every time the page repaints.
function jitter(i, spread) {
var n = Math.sin((i + 1) * 12.9898) * 43758.5453;
return ((n - Math.floor(n)) * 2 - 1) * (spread || 1);
}
// fly moves one chip from one place to another and resolves when it lands.
//
// The arc is the point. A straight line between two rects reads as a UI
// transition; a chip that rises, travels and drops reads as a throw. WAAPI does
// this in one keyframe list because it can interpolate a midpoint — a CSS
// transition can't, which is why this isn't a class toggle.
function fly(from, to, opts) {
opts = opts || {};
var a = centre(from);
var b = centre(to);
var el = disc(opts.denom || 25);
el.className += " pete-fly";
stage().appendChild(el);
var dur = opts.duration || 420;
if (reduced) dur = 1;
// How high it goes: further throws arc higher, and a lob can be forced.
var dist = Math.hypot(b.x - a.x, b.y - a.y);
var lift = Math.min(120, 28 + dist * 0.18) * (opts.lift == null ? 1 : opts.lift);
var midX = (a.x + b.x) / 2;
var midY = (a.y + b.y) / 2 - lift;
var spin = opts.spin == null ? jitter(opts.index || 0, 90) : opts.spin;
var anim = el.animate(
[
{ transform: t(a.x, a.y, 0.85, 0), opacity: 1, offset: 0 },
{ transform: t(midX, midY, 1.12, spin * 0.6), opacity: 1, offset: 0.5 },
{ transform: t(b.x, b.y, 1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
],
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
);
return anim.finished
.catch(function () {})
.then(function () {
el.remove();
});
}
function t(x, y, scale, rot) {
return "translate(" + x + "px," + y + "px) scale(" + scale + ") rotate(" + rot + "deg)";
}
// flyMany throws a whole bet across, one chip after another rather than all at
// once, because a pile of chips arriving as a single object is a progress bar.
// Resolves when the last one lands.
function flyMany(from, to, denoms, opts) {
opts = opts || {};
var gap = reduced ? 0 : opts.gap == null ? 70 : opts.gap;
var each = denoms.map(function (d, i) {
return fly(from, to, {
denom: d,
index: i,
delay: (opts.delay || 0) + i * gap,
duration: opts.duration,
lift: opts.lift,
fade: opts.fade,
spin: opts.spin,
});
});
if (opts.onLand && !reduced) {
denoms.forEach(function (d, i) {
setTimeout(opts.onLand, (opts.delay || 0) + i * gap + (opts.duration || 420), d, i);
});
} else if (opts.onLand) {
denoms.forEach(function (d, i) { opts.onLand(d, i); });
}
return Promise.all(each);
}
// burst: confetti out of a point. Saved for the things worth celebrating.
function burst(target, opts) {
if (reduced) return Promise.resolve();
opts = opts || {};
var c = centre(target);
var n = opts.count || 26;
var colours = opts.colours || ["#f2b53d", "#4caf7d", "#5aa9e6", "#b079d6", "#ffffff", "#cc3d4a"];
var done = [];
for (var i = 0; i < n; i++) {
var bit = document.createElement("div");
bit.className = "pete-spark";
bit.style.background = colours[i % colours.length];
stage().appendChild(bit);
// Fired into a cone that mostly goes up, then let gravity have it.
var angle = (-Math.PI / 2) + jitter(i, 1) * (opts.spread || 1.15);
var speed = 120 + Math.abs(jitter(i + 7, 1)) * 190;
var vx = Math.cos(angle) * speed;
var vy = Math.sin(angle) * speed;
var dropTo = c.y + 220 + jitter(i + 3, 60);
done.push(
bit
.animate(
[
{ transform: t(c.x, c.y, 0.6, 0), opacity: 1, offset: 0 },
{
transform: t(c.x + vx * 0.45, c.y + vy * 0.45, 1, jitter(i, 180)),
opacity: 1,
offset: 0.4,
},
// Held at full colour most of the way down: a piece of confetti that
// starts fading the moment it's thrown reads as smoke.
{
transform: t(c.x + vx * 0.75, c.y + vy * 0.3 + 110, 0.95, jitter(i, 380)),
opacity: 1,
offset: 0.75,
},
{
transform: t(c.x + vx * 0.9, dropTo, 0.9, jitter(i, 540)),
opacity: 0,
offset: 1,
},
],
{
duration: 900 + Math.abs(jitter(i + 11, 1)) * 500,
easing: "cubic-bezier(0.18, 0.7, 0.4, 1)",
fill: "both",
}
)
.finished.catch(function () {})
.then(
(function (b) {
return function () { b.remove(); };
})(bit)
)
);
}
return Promise.all(done);
}
// count rolls a number to a new value instead of swapping it. A chip count that
// jumps is a variable; one that climbs is a payout.
function count(el, to, opts) {
if (!el) return;
opts = opts || {};
var from = parseInt(String(el.textContent).replace(/[^0-9-]/g, ""), 10);
if (isNaN(from) || reduced || from === to) {
el.textContent = (to || 0).toLocaleString();
return;
}
var dur = opts.duration || 520;
var t0 = null;
if (el._petePop) el._petePop.cancel();
el._petePop = el.animate(
[{ transform: "scale(1)" }, { transform: "scale(1.12)" }, { transform: "scale(1)" }],
{ duration: dur, easing: "ease-out" }
);
function step(ts) {
if (t0 === null) t0 = ts;
var p = Math.min(1, (ts - t0) / dur);
var eased = 1 - Math.pow(1 - p, 3);
el.textContent = Math.round(from + (to - from) * eased).toLocaleString();
if (p < 1) requestAnimationFrame(step);
else el.textContent = (to || 0).toLocaleString();
}
requestAnimationFrame(step);
}
window.PeteFX = {
reduced: reduced,
chipsFor: chipsFor,
disc: disc,
jitter: jitter,
fly: fly,
flyMany: flyMany,
burst: burst,
count: count,
centre: centre,
};
})();

View File

@@ -41,8 +41,16 @@
}
function paint(v) {
var first = view === null;
view = v;
if (chipsEl) chipsEl.textContent = money(v.chips);
// The chip count rolls to its new value rather than jumping to it — the table
// times this to land with the chips that caused it, so a payout reads as the
// number catching up with the felt. On the first paint there's nothing to
// catch up with, so it just sets.
if (chipsEl) {
if (first || !window.PeteFX) chipsEl.textContent = money(v.chips);
else window.PeteFX.count(chipsEl, v.chips);
}
if (eurosEl) eurosEl.textContent = (v.euros || 0).toFixed(2);
if (pendingEl) {