// 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); } // 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, flyMany: flyMany, spot: spot, burst: burst, count: count, centre: centre, }; })();