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

@@ -304,12 +304,22 @@
colourEl.textContent = "";
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) {
billEl.textContent = n > 0 ? "+" + n + " on you" : "";
billEl.textContent = n > 0 ? "+" + n + " on " + pendingTarget(v) : "";
billEl.classList.toggle("hidden", n <= 0);
}
if (takeN) takeN.textContent = String(n);
@@ -355,7 +365,7 @@
if (!text) { verdictEl.classList.add("hidden"); return; }
verdictEl.textContent = text;
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");
}
@@ -475,6 +485,80 @@
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.
function play(view) {
var events = view.uno_events || [];
@@ -570,7 +654,7 @@
}
case "stack":
pending(e.n);
pending(e.n, final);
spotlight(e.seat);
FX.sfx("bad");
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
@@ -614,8 +698,12 @@
bump(e.seat, 0);
showHand(e.hand);
pending(0);
FX.sfx("bad");
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
// bury() rolls the send-off and makes its own noise; the badge rides
// 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":