A code review of the uno table found the stuck guard had never once fired. It counted how many bots had passed in a row and wanted more of them than there are seats — but the bot loop hands the turn back the moment it comes round to you, so the count could never get there, and your own empty-handed pass was never in it. A dead table just passed the turn round forever. That is not an ugly ending, it's a game you cannot finish, and a game you cannot finish is chips you cannot cash out. So it asks the real question now: is there anything to draw, and is anyone holding a card that goes. And the table let go of itself too early. busy came off when the request landed, not when the script it came back with had finished playing — so for the seconds a bot lap takes, you could click a card at a board the server had already moved past. It comes off at the end now, like the other tables. Also: left: 0 was being dropped on its way out the door, which is the one number that matters (the seat that just went out), the deck counter didn't come back after a reshuffle, and hoisting fly() into flyNode() had quietly flattened the chip arc on every other table in the room. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
341 lines
13 KiB
JavaScript
341 lines
13 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);
|
|
}
|
|
|
|
// flyNode moves *anything* from one place to another and resolves when it
|
|
// lands: a chip, a playing card, a card face down.
|
|
//
|
|
// The arc is the point. A straight line between two rects reads as a UI
|
|
// transition; something 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.
|
|
//
|
|
// The node is yours: build it, hand it over, and it is gone when the promise
|
|
// resolves. Nothing in here knows or cares what it was.
|
|
function flyNode(node, from, to, opts) {
|
|
opts = opts || {};
|
|
var a = centre(from);
|
|
var b = centre(to);
|
|
node.classList.add("pete-fly");
|
|
stage().appendChild(node);
|
|
|
|
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 s0 = opts.fromScale == null ? 0.85 : opts.fromScale;
|
|
var s1 = opts.toScale == null ? 1 : opts.toScale;
|
|
// Bigger at the top of the arc than at either end — that swell is what sells
|
|
// the thing as coming towards you. Taken off the larger end, so a throw that
|
|
// starts small and lands full size still peaks above where it lands, rather
|
|
// than averaging itself back down to nothing.
|
|
var sMid = Math.max(s0, s1) * 1.12;
|
|
|
|
var anim = node.animate(
|
|
[
|
|
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
|
|
{ transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 },
|
|
{ transform: t(b.x, b.y, s1, 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 () {
|
|
node.remove();
|
|
});
|
|
}
|
|
|
|
// fly throws one chip. It is flyNode with a chip in it, which is what it always
|
|
// was — UNO wanted the same throw with a card in it, so the throw moved out.
|
|
function fly(from, to, opts) {
|
|
opts = opts || {};
|
|
return flyNode(disc(opts.denom || 25), from, to, opts);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// A bet spot: the pile of chips sitting on it, and the number printed under
|
|
// the pile.
|
|
//
|
|
// The rule the whole room is built on lives in here, which is why it's one
|
|
// object and not two variables on a table: **the number is a readout of the
|
|
// pile, never the other way round.** There is no way to change one without the
|
|
// other, so a settled game can't leave "your bet: 300" printed over an empty
|
|
// circle, and a payout can't be counted before the chips that justify it have
|
|
// landed.
|
|
//
|
|
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
|
|
// holds what you've banked, which grows a card at a time. Same object.
|
|
function spot(els) {
|
|
var api = {
|
|
// What the pile is holding. Written by render, and readable by a table that
|
|
// needs to know whether a chip is already on its way down.
|
|
amount: 0,
|
|
|
|
render: function (n) {
|
|
api.amount = n || 0;
|
|
els.stack.innerHTML = "";
|
|
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
|
|
if (!api.amount) {
|
|
if (els.total) els.total.classList.add("hidden");
|
|
return;
|
|
}
|
|
chipsFor(api.amount).forEach(function (d, i) {
|
|
var c = disc(d);
|
|
c.style.setProperty("--i", i);
|
|
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
|
|
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
|
|
els.stack.appendChild(c);
|
|
});
|
|
if (els.total) {
|
|
els.total.textContent = api.amount.toLocaleString();
|
|
els.total.classList.remove("hidden");
|
|
}
|
|
},
|
|
|
|
// pour throws a run of chips onto the spot and grows the pile 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.
|
|
pour: function (from, amount, opts) {
|
|
if (amount <= 0) return Promise.resolve();
|
|
var base = api.amount;
|
|
var chips = chipsFor(amount, 8);
|
|
var run = 0;
|
|
return flyMany(from, els.spot, chips, Object.assign({
|
|
onLand: function (d, i) {
|
|
run += d;
|
|
api.render(base + (i === chips.length - 1 ? amount : run));
|
|
},
|
|
}, opts || {}));
|
|
},
|
|
|
|
// sweep sends chips off the spot to somewhere else — your pile, or the
|
|
// house's rack. The spot is emptied *now* rather than when they land, so
|
|
// nothing that is already in the air can be bet a second time.
|
|
sweep: function (to, amount, opts) {
|
|
var n = amount == null ? api.amount : amount;
|
|
var left = api.amount - n;
|
|
if (n <= 0) return Promise.resolve();
|
|
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
|
|
api.render(left > 0 ? left : 0);
|
|
return chain;
|
|
},
|
|
};
|
|
return api;
|
|
}
|
|
|
|
// 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,
|
|
flyNode: flyNode,
|
|
flyMany: flyMany,
|
|
spot: spot,
|
|
burst: burst,
|
|
count: count,
|
|
centre: centre,
|
|
};
|
|
})();
|