games: burial send-offs, money rain on wins, and honest stack targeting

Three front-end changes to the casino games:

- uno: a draw-card stack now names the seat it's actually pointed at
  ("+N on Beep") instead of always reading "+N on you". The bill reads
  v.turn, which the engine advances to the target when a draw card lands.

- uno: the No Mercy rule buries a seat with some ceremony now. bury()
  rolls one of four send-offs over the seat (or your hand, if it's you) —
  a rockslide that piles up, a tombstone, a coffin, or the Mega Man death
  burst — each with its own synth sound, and a static headstone under
  reduced motion. The "Buried on N" badge still rides alongside.

- all games: winning makes it rain. FX.moneyRain drops a curtain of bills
  and coins with a jackpot coin-cascade sound, gated by significance so it
  reads as a win and not as noise: hold'em keeps its light per-pot confetti
  and only rains on a real haul (>= 20bb), while the confetti stays the
  rare cherry (natural 21, full board clear, phrase solved outright).
This commit is contained in:
prosolis
2026-07-15 18:37:19 -07:00
parent e0d90ff7cc
commit 8504c4f47a
10 changed files with 326 additions and 25 deletions

View File

@@ -940,6 +940,18 @@ html[data-phase="night"] {
will-change: transform, opacity; will-change: transform, opacity;
} }
/* Falling money — the win curtain. Positioned entirely by the transform WAAPI
drives (see moneyRain in casino-fx.js), so it starts pinned to the top-left. */
.pete-money {
position: absolute;
top: 0;
left: 0;
font-size: 1.6rem;
line-height: 1;
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.35));
will-change: transform, opacity;
}
/* The dealer's beat before they draw out. */ /* The dealer's beat before they draw out. */
.pete-dealer-think { .pete-dealer-think {
animation: pete-think 0.9s ease-in-out infinite; animation: pete-think 0.9s ease-in-out infinite;
@@ -1978,6 +1990,93 @@ html[data-phase="night"] {
} }
.pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; } .pete-uno-seat[data-out="1"] .pete-uno-count { color: #ffb3ba; }
/* The send-off. When the mercy rule buries a seat, uno.js rolls one of four
of these and drops it over that seat — or over your hand, if it's you going
down. Every piece is built fresh and clears itself, so it can't outlive the
hand. The wrap is a zero-size anchor at the seat's middle; the pieces hang
off it. */
.pete-uno-bury {
position: absolute;
left: 50%;
top: 45%;
width: 0;
height: 0;
z-index: 40;
pointer-events: none;
}
/* Rockslide: a handful of boulders come down and pile where the seat was. */
.pete-uno-rock {
position: absolute;
left: 0;
top: 0;
font-size: 1.5rem;
line-height: 1;
filter: drop-shadow(0 2px 1px rgba(0,0,0,0.4));
animation: pete-bury-rock 0.6s cubic-bezier(0.5, 0, 0.7, 1) both;
animation-delay: var(--d, 0ms);
}
@keyframes pete-bury-rock {
0% { transform: translate(calc(-50% + var(--dx)), -3.4rem) rotate(0deg); opacity: 0; }
20% { opacity: 1; }
75% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); }
85% { transform: translate(calc(-50% + var(--dx)), calc(var(--dy) - 0.22rem)) rotate(var(--rot)); }
100% { transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)); opacity: 1; }
}
/* Tombstone and coffin: one heavy thing drops in and sets, with a small bounce. */
.pete-uno-tomb,
.pete-uno-coffin {
position: absolute;
left: 0;
top: 0;
line-height: 1;
filter: drop-shadow(0 3px 2px rgba(0,0,0,0.45));
animation: pete-bury-drop 0.5s cubic-bezier(0.34, 1.3, 0.64, 1) both;
}
.pete-uno-tomb { font-size: 2.4rem; }
.pete-uno-coffin { font-size: 2.5rem; }
@keyframes pete-bury-drop {
0% { transform: translate(-50%, -3.2rem) scale(0.7); opacity: 0; }
55% { opacity: 1; }
70% { transform: translate(-50%, -50%) scale(1.06); }
82% { transform: translate(-50%, -44%) scale(1); }
100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
/* The Mega Man death: a flash, then eight pieces fire out and wink off. */
.pete-uno-zap-flash {
position: absolute;
left: 0;
top: 0;
width: 1.5rem;
height: 1.5rem;
margin: -0.75rem 0 0 -0.75rem;
border-radius: 999px;
background: radial-gradient(circle, #fff 0%, rgba(180,220,255,0) 70%);
animation: pete-bury-flash 0.35s ease-out both;
}
@keyframes pete-bury-flash {
0% { transform: scale(0.3); opacity: 0.95; }
100% { transform: scale(1.9); opacity: 0; }
}
.pete-uno-zap-bit {
position: absolute;
left: 0;
top: 0;
width: 0.5rem;
height: 0.5rem;
margin: -0.25rem 0 0 -0.25rem;
border-radius: 999px;
background: #cfeaff;
box-shadow: 0 0 6px 1px rgba(150,210,255,0.9);
animation: pete-bury-zap 0.5s ease-out both;
}
@keyframes pete-bury-zap {
0% { transform: rotate(var(--ang)) translateX(0.2rem); opacity: 1; }
100% { transform: rotate(var(--ang)) translateX(3.4rem); opacity: 0; }
}
/* The rules switch: two dials, and this is the one that isn't the table size. */ /* The rules switch: two dials, and this is the one that isn't the table size. */
.pete-seg { .pete-seg {
display: inline-flex; display: inline-flex;
@@ -2043,6 +2142,9 @@ html[data-phase="night"] {
.pete-uno-card[data-glow="1"]::before { animation: none; } .pete-uno-card[data-glow="1"]::before { animation: none; }
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; } .pete-uno-card[data-glow="1"] .pete-uno-face::after { display: none; }
.pete-uno-pending { animation: none; } .pete-uno-pending { animation: none; }
/* The grave still gets marked — that's information — it just doesn't fall in.
uno.js drops a single static headstone here rather than the full rockslide. */
.pete-uno-tomb { animation: none; transform: translate(-50%, -50%); }
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -265,13 +265,10 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// The one thing in this room that gets confetti. A natural is rare, it pays // Every win gets it to rain, because a win should feel like one. A natural is
// 3:2, and if everything celebrated then nothing would. // rarer and pays 3:2, so it keeps the confetti on top of the money as well.
// if (v.outcome === "blackjack") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
// The *sound* is not so precious: a win is a win and you should hear it. So else if (v.net > 0) FX.moneyRain();
// the fanfare rides on the money, not on the confetti.
if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 });
else if (v.net > 0) FX.sfx("win");
else if (v.net < 0) FX.sfx("lose"); else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push"); else FX.sfx("push");
} }

View File

@@ -319,6 +319,65 @@
return Promise.all(done); return Promise.all(done);
} }
// moneyRain: it rains money. The big finish on a win — bills and coins tumbling
// the whole height of the window, swaying and spinning as they fall. Louder than
// confetti, and it carries its own sound (a coin cascade) so it can stand in for
// the plain win fanfare wherever a table decides a win is worth the theatre.
//
// Like burst, the sound comes before the reduced-motion bail: no rain still
// deserves to be heard.
function moneyRain(opts) {
opts = opts || {};
if (opts.sound !== false) sfx(opts.sound || "jackpot");
if (reduced) return Promise.resolve();
var n = opts.count || 22;
var glyphs = opts.glyphs || ["💶", "💰", "🪙", "💵", "💴", "🤑"];
var vw = window.innerWidth || document.documentElement.clientWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var done = [];
for (var i = 0; i < n; i++) {
var bit = document.createElement("div");
bit.className = "pete-money";
bit.textContent = glyphs[i % glyphs.length];
stage().appendChild(bit);
// Spread across the width in even lanes, each nudged off its lane so the
// curtain doesn't read as a grid. It sways sideways and spins on the way down.
var x = ((i + 0.5) / n) * vw + jitter(i, vw / n / 2);
var drift = jitter(i + 5, 80);
var startY = -50 - Math.abs(jitter(i + 2, 180));
var spin = jitter(i, 300);
var scale = 0.85 + Math.abs(jitter(i + 3, 0.55));
done.push(
bit
.animate(
[
{ transform: t(x, startY, scale, 0), opacity: 0, offset: 0 },
{ transform: t(x + drift, startY + 40, scale, spin * 0.2), opacity: 1, offset: 0.1 },
{ transform: t(x - drift, vh * 0.55, scale, spin * 0.6), opacity: 1, offset: 0.6 },
{ transform: t(x + drift * 0.5, vh + 60, scale, spin), opacity: 1, offset: 1 },
],
{
duration: 1600 + Math.abs(jitter(i + 9, 1)) * 900,
easing: "cubic-bezier(0.35, 0.15, 0.55, 1)",
fill: "both",
delay: Math.abs(jitter(i, 320)),
}
)
.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 // 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. // jumps is a variable; one that climbs is a payout.
function count(el, to, opts) { function count(el, to, opts) {
@@ -359,6 +418,7 @@
flyMany: flyMany, flyMany: flyMany,
spot: spot, spot: spot,
burst: burst, burst: burst,
moneyRain: moneyRain,
count: count, count: count,
centre: centre, centre: centre,
}; };

View File

@@ -264,6 +264,17 @@
}); });
}, },
// It rains money. A bright run of coins spilling out over the win fanfare —
// ten metallic pings climbing a pentatonic scale, each with a tick on the
// front of it so it lands as coin rather than as bell.
jackpot: function (t) {
var notes = [784, 988, 1175, 1319, 1568, 1760];
for (var i = 0; i < 10; i++) {
tone(t + i * 0.05, notes[i % notes.length], { type: "triangle", decay: 0.13, gain: 0.09 });
hiss(t + i * 0.05, { freq: 5200, q: 3, decay: 0.02, gain: 0.05 });
}
},
// Two notes down, and the second one is flat. Nobody needs telling twice. // Two notes down, and the second one is flat. Nobody needs telling twice.
lose: function (t) { lose: function (t) {
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 }); tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
@@ -307,6 +318,43 @@
tick: function (t) { tick: function (t) {
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 }); hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
}, },
// The mercy rule buries someone. Four ways to go, one sound each — uno.js
// rolls which animation plays and asks for the sound that matches it.
//
// Rocks coming down: a low rumble with tumbling knocks over the top, and a
// second slump as the pile settles.
rockslide: function (t) {
hiss(t, { filter: "lowpass", freq: 320, sweepTo: 140, decay: 0.5, gain: 0.34, q: 0.6 });
for (var i = 0; i < 6; i++) {
tone(t + i * 0.055, 128 - i * 9, { type: "square", decay: 0.1, gain: 0.09 });
}
hiss(t + 0.3, { filter: "lowpass", freq: 200, decay: 0.32, gain: 0.3, q: 0.5 });
},
// A headstone dropping in: one heavy stone thud that rings a little as it sets.
tombstone: function (t) {
tone(t, 92, { type: "sine", to: 58, glide: 0.16, decay: 0.42, gain: 0.32 });
hiss(t, { filter: "lowpass", freq: 520, decay: 0.12, gain: 0.34, q: 0.6 });
tone(t + 0.02, 184, { type: "triangle", decay: 0.5, gain: 0.08 });
},
// The Mega Man death: the pieces zip outward and the pitch falls away as they go.
zap: function (t) {
tone(t, 1250, { type: "sawtooth", to: 170, glide: 0.34, decay: 0.36, gain: 0.12 });
for (var i = 0; i < 4; i++) {
tone(t + i * 0.02, 940 - i * 130, { type: "square", decay: 0.06, gain: 0.06 });
}
hiss(t, { freq: 3000, sweepTo: 600, decay: 0.3, gain: 0.1, q: 0.8 });
},
// A coffin lid: two flat wooden knocks, the second lower and heavier.
coffin: function (t) {
tone(t, 150, { type: "triangle", decay: 0.14, gain: 0.26 });
hiss(t, { filter: "lowpass", freq: 820, decay: 0.05, gain: 0.24, q: 0.7 });
tone(t + 0.17, 108, { type: "triangle", decay: 0.22, gain: 0.28 });
hiss(t + 0.17, { filter: "lowpass", freq: 600, decay: 0.06, gain: 0.24, q: 0.7 });
},
}; };
// ---- the door -------------------------------------------------------------- // ---- the door --------------------------------------------------------------

View File

@@ -293,10 +293,10 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// Confetti for a phrase guessed outright — the one call you make on your own // A phrase guessed outright keeps the confetti — the one call you make on your
// rather than by grinding the alphabet. // own rather than by grinding the alphabet — and any win at all makes it rain.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 }); if (v.outcome === "solved" && v.net > 0) { FX.burst(verdictEl, { count: 30 }); FX.moneyRain({ count: 28 }); }
else if (v.net > 0) FX.sfx("win"); else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose"); else if (v.net < 0) FX.sfx("lose");
} }

View File

@@ -370,7 +370,12 @@
return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () { return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
potTotal.textContent = money(pot.amount); potTotal.textContent = money(pot.amount);
moveStack(e.seat, e.amount); moveStack(e.seat, e.amount);
if (e.seat === me && e.amount > 0) FX.burst(s.plate, { count: 18 }); if (e.seat === me && e.amount > 0) {
FX.burst(s.plate, { count: 18 });
// A real haul makes it rain; a blind-steal doesn't, or poker would be
// raining money every thirty seconds. Twenty big blinds is a pot.
if (view && view.tier && e.amount >= 20 * view.tier.bb) FX.moneyRain({ count: 24 });
}
return wait(260); return wait(260);
}); });

View File

@@ -576,9 +576,9 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in // Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
// this room, so it's the one that gets the confetti. // this room, so it keeps the confetti — but any win now makes it rain.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 }); if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
else if (v.net > 0) FX.sfx("win"); else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose"); else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push"); else FX.sfx("push");
} }

View File

@@ -307,8 +307,9 @@
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
// Confetti only for clearing all twelve the one thing in here worth it. // Clearing all twelve keeps the confetti on top; any win at all makes it rain.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 34 }); if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
else if (v.net > 0) FX.moneyRain();
} }
function setPhase(v) { function setPhase(v) {

View File

@@ -304,12 +304,22 @@
colourEl.textContent = ""; colourEl.textContent = "";
if (feltEl) feltEl.dataset.c = ""; if (feltEl) feltEl.dataset.c = "";
} }
pending(live(v) ? (v.pending || 0) : 0); pending(live(v) ? (v.pending || 0) : 0, v);
} }
function pending(n) { // Who the running stack lands on next: during a stack phase that's whoever's
// turn it is. Fall back to the view the caller passed, then to the live game.
function pendingTarget(v) {
var src = v || game;
if (!src) return "you";
if (src.turn === me) return "you";
var s = (src.seats && src.seats[src.turn]) || null;
return (s && s.name) || "them";
}
function pending(n, v) {
if (billEl) { if (billEl) {
billEl.textContent = n > 0 ? "+" + n + " on you" : ""; billEl.textContent = n > 0 ? "+" + n + " on " + pendingTarget(v) : "";
billEl.classList.toggle("hidden", n <= 0); billEl.classList.toggle("hidden", n <= 0);
} }
if (takeN) takeN.textContent = String(n); if (takeN) takeN.textContent = String(n);
@@ -355,7 +365,7 @@
if (!text) { verdictEl.classList.add("hidden"); return; } if (!text) { verdictEl.classList.add("hidden"); return; }
verdictEl.textContent = text; verdictEl.textContent = text;
verdictEl.classList.remove("hidden"); verdictEl.classList.remove("hidden");
if (v.winner === me && v.outcome !== "tie") FX.burst(verdictEl, { count: 34 }); if (v.winner === me && v.outcome !== "tie") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose"); else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
} }
@@ -475,6 +485,80 @@
return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); }); return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
} }
// bury gives the mercy rule some ceremony. It rolls one of four send-offs and
// drops it over the buried seat — or over your hand, if it's you going down —
// with the noise that goes with it. Every piece is built from scratch and
// clears itself, so a repaint that lands mid-fall just cuts it short. Resolves
// on the dramatic beat, not on cleanup: the hand shouldn't wait for the dust.
var BURIALS = ["rocks", "tomb", "zap", "coffin"];
function bury(seat) {
var host = seat === me ? handEl : seatEl(seat);
if (!host) return Promise.resolve();
var wrap = document.createElement("div");
wrap.className = "pete-uno-bury";
host.appendChild(wrap);
// No theatre for reduced motion: a single static headstone marks the grave,
// with the stone-drop thud so it isn't silent either.
if (reduced) {
FX.sfx("tombstone");
var stone = document.createElement("div");
stone.className = "pete-uno-tomb";
stone.textContent = "🪦";
wrap.appendChild(stone);
setTimeout(function () { wrap.remove(); }, 1400);
return Promise.resolve();
}
var kind = BURIALS[Math.floor(Math.random() * BURIALS.length)];
wrap.dataset.kind = kind;
if (kind === "rocks") {
FX.sfx("rockslide");
[
{ dx: "-1.15rem", dy: "0.55rem", rot: "-20deg", d: 0 },
{ dx: "0.95rem", dy: "0.7rem", rot: "24deg", d: 70 },
{ dx: "-0.15rem", dy: "0.15rem", rot: "8deg", d: 150 },
{ dx: "-1.5rem", dy: "1.0rem", rot: "-34deg", d: 220 },
{ dx: "1.45rem", dy: "0.95rem", rot: "16deg", d: 300 },
{ dx: "0.35rem", dy: "1.05rem", rot: "-10deg", d: 360 },
].forEach(function (r) {
var el = document.createElement("div");
el.className = "pete-uno-rock";
el.textContent = "🪨";
el.style.setProperty("--dx", r.dx);
el.style.setProperty("--dy", r.dy);
el.style.setProperty("--rot", r.rot);
el.style.setProperty("--d", r.d + "ms");
wrap.appendChild(el);
});
} else if (kind === "zap") {
FX.sfx("zap");
var flash = document.createElement("div");
flash.className = "pete-uno-zap-flash";
wrap.appendChild(flash);
for (var a = 0; a < 8; a++) {
var bit = document.createElement("div");
bit.className = "pete-uno-zap-bit";
bit.style.setProperty("--ang", (a * 45) + "deg");
wrap.appendChild(bit);
}
} else {
// tomb or coffin: one heavy thing drops in and sets.
FX.sfx(kind === "tomb" ? "tombstone" : "coffin");
var mark = document.createElement("div");
mark.className = kind === "tomb" ? "pete-uno-tomb" : "pete-uno-coffin";
mark.textContent = kind === "tomb" ? "🪦" : "⚰️";
wrap.appendChild(mark);
}
// The zap is gone in half a second; the graves linger before they clear.
setTimeout(function () { wrap.remove(); }, kind === "zap" ? 650 : 1500);
return new Promise(function (r) { setTimeout(r, kind === "zap" ? 480 : 640); });
}
// play walks the server's script for a move the acting player just made. // play walks the server's script for a move the acting player just made.
function play(view) { function play(view) {
var events = view.uno_events || []; var events = view.uno_events || [];
@@ -570,7 +654,7 @@
} }
case "stack": case "stack":
pending(e.n); pending(e.n, final);
spotlight(e.seat); spotlight(e.seat);
FX.sfx("bad"); FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); }); return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
@@ -614,8 +698,12 @@
bump(e.seat, 0); bump(e.seat, 0);
showHand(e.hand); showHand(e.hand);
pending(0); pending(0);
FX.sfx("bad"); // bury() rolls the send-off and makes its own noise; the badge rides
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); }); // alongside it so you still see what count did them in.
return Promise.all([
bury(e.seat),
badge(e.seat, "Buried on " + e.n, "bad"),
]).then(function () { return wait(300); });
} }
case "skip": case "skip":