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.
250 lines
8.6 KiB
JavaScript
250 lines
8.6 KiB
JavaScript
// 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,
|
|
};
|
|
})();
|