7 Commits

Author SHA1 Message Date
prosolis
b814f936a8 solitaire: detect a won board and finish it in one press
A drained, all-face-up board is a guaranteed clear that auto() sweeps home
in a single cascade, but the only way to trigger it was double-clicking
thirty cards home by hand. Add State.Won(), surface it in the view, and
swap the ordinary controls for one pulsing finish button when it's true.
2026-07-15 18:50:43 -07:00
prosolis
8504c4f47a 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).
2026-07-15 18:37:19 -07:00
prosolis
e0d90ff7cc pwa: serve static assets network-first so redeploys stop serving stale JS/CSS
Asset URLs are not content-hashed, so the cache-first strategy pinned
whatever bytes were first cached and never noticed a redeployed file
changed — leaving stale weather-gl.js/output.css in place until a manual
CACHE_VERSION bump. Only a hard reload (which bypasses the SW) showed the
new assets. Switch /static/ to network-first (fall back to cache offline)
and bump CACHE_VERSION v4->v5 to flush the stale shell.
2026-07-15 17:45:53 -07:00
prosolis
790a118273 games: widen foley pitch variance so takes are actually distinct
vary was a per-step multiplier of ~0.05, capping the swing at a barely
audible +/-0.10. Redefine vary as the max deviation itself (chips +/-0.22,
cards +/-0.20) and spread it over seven pitch steps; give the single-take
shuffle a wobble too.
2026-07-14 23:51:21 -07:00
prosolis
8df2212aad games: drop chipLay3, it clashed with the other two chip takes 2026-07-14 23:48:58 -07:00
prosolis
1ca794ea1a games: rotate through foley takes on every play, not just by v
Take-selection was keyed only on the caller's v, but callers pass small
constants (chip is always v:0), so only the first take of each sound ever
played. Add a per-name cursor that advances every call: consecutive plays
now walk the takes, v just offsets simultaneous sounds and drives pitch.
2026-07-14 23:44:24 -07:00
prosolis
30b0e8debb games: real recorded foley for cards and chips, synth for the rest
The card and chip sounds never convinced as oscillators, so they're now
CC0 recordings (Kenney's casino pack) under /static/audio/casino, embedded
in the binary. Six names (card, deal, flip, shuffle, chip, sweep) play
several rotating takes with a little pitch jitter; everything melodic stays
synthesised. PeteSFX's API is unchanged and synthesis is the fallback while
an ogg is still decoding, so no caller changed and the table is never silent.
2026-07-14 23:28:10 -07:00
34 changed files with 593 additions and 48 deletions

View File

@@ -620,6 +620,31 @@ func (s State) Net() int64 {
// cleared reports whether every card is home.
func (s State) cleared() bool { return s.Home() == FullDeck }
// Won reports that the board is a guaranteed clear: nothing left in the stock or
// waste, and not a single face-down card under any column. From here every card
// is a face-up run and a single auto() drains the whole board to the foundations
// without a choice left to make — which is exactly the tedious tail the felt
// should offer to finish in one gesture instead of thirty double-clicks.
//
// It's deliberately narrower than "solvable": a draw-one board with cards still
// in the stock is winnable too, but auto() won't turn the stock over, so calling
// that won would light a finish button that then stalls. This is the state the
// finish button actually finishes.
func (s State) Won() bool {
if s.Phase == PhaseDone || s.cleared() {
return false
}
if len(s.Stock) > 0 || len(s.Waste) > 0 {
return false
}
for _, p := range s.Table {
if len(p.Down) > 0 {
return false
}
}
return true
}
// CanAuto reports whether anything can go home at all — which is what greys the
// finish button out rather than letting it be pressed at a board that has nothing
// for it.

View File

@@ -423,6 +423,52 @@ func TestAutoSendsEverythingItCanHome(t *testing.T) {
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
}
// Won is the state the finish button finishes: every card face up, the stock and
// waste drained, so a single auto sweeps the lot home. The button is only honest
// if these two agree — Won lighting up on a board auto can't clear would stall.
func TestWonIsExactlyWhatAutoCanFinish(t *testing.T) {
s := board(patient(), 5200)
// A won board: three short face-up runs across the columns, nothing face down,
// nothing in the stock or waste. Every card is reachable.
s.Table[0].Up = []cards.Card{card(2, cards.Spades), card(cards.Ace, cards.Hearts)}
s.Table[1].Up = []cards.Card{card(2, cards.Hearts), card(cards.Ace, cards.Spades)}
s.Table[2].Up = []cards.Card{card(2, cards.Diamonds), card(cards.Ace, cards.Clubs)}
s.Table[3].Up = []cards.Card{card(2, cards.Clubs), card(cards.Ace, cards.Diamonds)}
if !s.Won() {
t.Fatalf("a board with everything face up and the piles empty is won")
}
next, _ := apply(t, s, Move{Kind: "auto"})
if next.Home() != 8 { // the eight cards laid out above
t.Fatalf("auto homed %d cards, want 8 — the whole won board", next.Home())
}
// Anything still hidden or still in a pile is not won: auto would stall on it.
withStock := s.clone()
withStock.Stock = cards.Deck{card(5, cards.Spades)}
if withStock.Won() {
t.Errorf("a board with a card still in the stock is not won — auto won't turn it over")
}
withWaste := s.clone()
withWaste.Waste = []cards.Card{card(5, cards.Spades)}
if withWaste.Won() {
t.Errorf("a board with a card still in the waste is not won")
}
withDown := s.clone()
withDown.Table[5].Down = []cards.Card{card(5, cards.Spades)}
if withDown.Won() {
t.Errorf("a board with a face-down card is not won")
}
// A finished board is never won: the button belongs to a game still in play.
cleared := s.clone()
cleared.Phase = PhaseDone
if cleared.Won() {
t.Errorf("a settled board reports won")
}
}
// ---- the money -------------------------------------------------------------
// The number the felt quotes while you play and the number settle() lands on are

View File

@@ -60,6 +60,7 @@ type solitaireView struct {
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
Moves int `json:"moves"`
CanAuto bool `json:"can_auto"`
Won bool `json:"won"` // every card face up, stock and waste empty: one press finishes it
Home int `json:"home"` // cards on the foundations
PerCard float64 `json:"per_card"` // what one more is worth
@@ -86,6 +87,7 @@ func viewSolitaire(g klondike.State) solitaireView {
Passes: g.PassesLeft(),
Moves: g.Moves,
CanAuto: g.CanAuto(),
Won: g.Won(),
Home: g.Home(),
PerCard: g.PerCard(),
BreakEven: g.Tier.BreakEven(),

View File

@@ -0,0 +1,21 @@
Casino Audio
by Kenney Vleugels (Kenney.nl)
------------------------------
License (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You may use these assets in personal and commercial projects.
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Follow on Twitter for updates:
@KenneyNL

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -940,6 +940,18 @@ html[data-phase="night"] {
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. */
.pete-dealer-think {
animation: pete-think 0.9s ease-in-out infinite;
@@ -1444,6 +1456,14 @@ html[data-phase="night"] {
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
}
/* The finish button on a won board breathes, so the one press left to make is
the one the eye lands on. */
.pete-finish { animation: pete-finish-pulse 1.6s ease-in-out infinite; }
@keyframes pete-finish-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(242, 181, 61, 0.55); }
50% { box-shadow: 0 0 0 0.9rem rgba(242, 181, 61, 0); }
}
/* The rail: the house's rack, what you've banked, the meter. On a wide screen
it's a column down the right of the felt; on a narrow one it lies down and
sits under the board. */
@@ -1978,6 +1998,93 @@ html[data-phase="night"] {
}
.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. */
.pete-seg {
display: inline-flex;
@@ -2031,7 +2138,8 @@ html[data-phase="night"] {
.pete-tile-hit,
.pete-meter[data-hit="1"] { animation: none; }
.pete-nope,
.pete-home-flash { animation: none; }
.pete-home-flash,
.pete-finish { animation: none; }
.pete-card[data-held="1"] { transition: none; }
/* The clock still drains — it is information, not decoration — but it stops
pulsing at you, and a wrong answer stops shaking. */
@@ -2043,6 +2151,9 @@ html[data-phase="night"] {
.pete-uno-card[data-glow="1"]::before { animation: none; }
.pete-uno-card[data-glow="1"] .pete-uno-face::after { display: 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.classList.remove("hidden");
// The one thing in this room that gets confetti. A natural is rare, it pays
// 3:2, and if everything celebrated then nothing would.
//
// The *sound* is not so precious: a win is a win and you should hear it. So
// 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");
// Every win gets it to rain, because a win should feel like one. A natural is
// 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 }); }
else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}

View File

@@ -319,6 +319,65 @@
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
// jumps is a variable; one that climbs is a payout.
function count(el, to, opts) {
@@ -359,6 +418,7 @@
flyMany: flyMany,
spot: spot,
burst: burst,
moneyRain: moneyRain,
count: count,
centre: centre,
};

View File

@@ -1,12 +1,18 @@
// The noise the room makes.
//
// There are no audio files here and there is nothing to download. Every sound in
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
// the same bargain the weather engine takes with its clouds. A card is a short
// slap of noise through a bandpass; a chip is two detuned sines with a click on
// the front; a win is four notes going up. It costs about six kilobytes and no
// round trips, and it means a sound can be pitched, stretched and detuned per
// call instead of being the same wav 300 times.
// The room speaks in two voices. The cards and chips are *recorded* — real clay
// and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
// files under /static/audio/casino. Synthesis never quite caught the grain of a
// chip landing on a chip, so for those we stopped trying. Everything melodic —
// the wins, the losses, the little ticks — is still *made*: an oscillator, a
// burst of filtered noise, an envelope, the same bargain the weather engine takes
// with its clouds. A win is four notes going up; a chip is a recording of a chip.
//
// A name is a recording if it appears in SAMPLES and synthesised if it appears in
// SOUNDS. The recordings guard against the one weakness of a sample — the same wav
// 300 times is a machine gun — by keeping several takes per sound and rotating
// through them, and by nudging every play a few percent off pitch. The synthesised
// half was always immune to that; it varies itself.
//
// Two rules hold the whole file up.
//
@@ -21,6 +27,10 @@
// default-off switch for the entire file, checked before anything else happens.
//
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
//
// If a recording hasn't finished decoding on the frame it's first needed, that one
// call falls through to the synthesised version — so the table is never silent
// while the ogg loads, and by the second card everything is real.
(function () {
"use strict";
@@ -108,6 +118,95 @@
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
}
// ---- the recordings --------------------------------------------------------
//
// The foley. Each name maps to a handful of takes; `v` (the index of the card or
// chip in a run) picks which one and how far off pitch it lands, so a dealt hand
// is four different cards rather than one card four times. `gain` is per-sound
// and multiplies the master, exactly like the synthesised sounds' peak does.
var SAMPLE_BASE = "/static/audio/casino/";
var SAMPLES = {
// A card thrown down onto the table. `vary` is the largest fraction a take is
// ever pitched by, up or down — 0.20 means a play can land anywhere from a fifth
// slower (deeper) to a fifth faster (brighter).
card: { files: ["cardPlace1", "cardPlace2", "cardPlace3", "cardPlace4"], gain: 0.6, vary: 0.20 },
// A card slid into place — softer, longer than a throw.
deal: { files: ["cardSlide1", "cardSlide2", "cardSlide3", "cardSlide4", "cardSlide5", "cardSlide6"], gain: 0.5, vary: 0.20 },
// A card flicked over.
flip: { files: ["cardFan1", "cardFan2"], gain: 0.5, vary: 0.18 },
// The riffle — one take, so the pitch wobble is all that keeps two shuffles apart.
shuffle: { files: ["cardShuffle"], gain: 0.6, vary: 0.10 },
// A clay chip set down on a stack.
chip: { files: ["chipLay1", "chipLay2"], gain: 0.6, vary: 0.22 },
// Chips gathered and slid away.
sweep: { files: ["chipsHandle2", "chipsHandle4", "chipsHandle6"], gain: 0.5, vary: 0.14 },
};
// Decoded buffers by filename. A value of null means "claimed, in flight or
// decoding"; false means "we tried and it failed, never bother again"; an
// AudioBuffer means ready.
var buffers = {};
var warmed = false;
// A rotating cursor per sound, so that even a run of identical calls walks
// through the takes rather than replaying the first one. Callers pass `v` to
// separate simultaneous sounds (the cards of one deal), but many pass the same
// constant every time — the cursor is what keeps a stack of chips from being the
// same click over and over.
var turn = {};
// decode fetches a file and turns it into a buffer, exactly once per filename.
// decodeAudioData exists in a modern promise flavour and an old callback one;
// this handles both so the foley works on older Safari too.
function decode(file) {
if (file in buffers) return; // ready, in flight, or known-bad
buffers[file] = null;
fetch(SAMPLE_BASE + file + ".ogg")
.then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(); })
.then(function (ab) {
return new Promise(function (res, rej) {
var p = ctx.decodeAudioData(ab, res, rej);
if (p && p.then) p.then(res, rej);
});
})
.then(function (buf) { buffers[file] = buf; })
.catch(function () { buffers[file] = false; });
}
// warm pulls every take down once the context is awake, so that after the first
// click the whole set is decoded and ready and nothing has to fall back.
function warm() {
if (warmed || !ctx) return;
warmed = true;
for (var name in SAMPLES) SAMPLES[name].files.forEach(decode);
}
// sample plays one recorded take. It returns false — rather than making a noise —
// when the name isn't a recording or its buffer isn't decoded yet, which is the
// caller's signal to reach for the synthesised version instead.
function sample(name, t0, v) {
var cfg = SAMPLES[name];
if (!cfg) return false;
// Advance the cursor, then offset it by v. The cursor guarantees consecutive
// calls move to the next take; v spreads apart sounds fired together (one
// deal's cards) so they don't all land on the same take at once.
var k = (turn[name] = (turn[name] || 0) + 1) + Math.abs(Math.round(v));
var buf = buffers[cfg.files[k % cfg.files.length]];
if (!buf) return false; // not decoded yet, or failed: let synth cover it
var src = ctx.createBufferSource();
src.buffer = buf;
// Pitch, walking with the same cursor across seven steps between -vary and
// +vary, so even a single-take sound like the shuffle isn't identical twice in
// a row. Seven steps rather than a couple means the runs don't read as a loop.
if (cfg.vary) src.playbackRate.value = 1 + (((k % 7) - 3) / 3) * cfg.vary;
var g = ctx.createGain();
g.gain.value = cfg.gain == null ? 0.6 : cfg.gain;
src.connect(g).connect(master);
src.start(t0);
return true;
}
// ---- the sounds ------------------------------------------------------------
//
// Each one takes the time it starts at, and a `v` — a small per-call variation,
@@ -165,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.
lose: function (t) {
tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 });
@@ -208,6 +318,43 @@
tick: function (t) {
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 --------------------------------------------------------------
@@ -217,6 +364,7 @@
// gesture — after which play() can schedule freely.
function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
warm();
}
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true });
@@ -231,13 +379,18 @@
// lands in 400ms can say so rather than sleeping.
function play(name, opts) {
if (muted) return;
var s = SOUNDS[name];
if (!s) return;
if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
if (!boot()) return;
wake();
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
var t0 = ctx.currentTime + ((opts && opts.delay) || 0);
var v = (opts && opts.v) || 0;
try {
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
// A recording if we have one decoded; the synthesised take otherwise — both
// for names that are only ever synthesised, and for the first call of a
// recorded name while its ogg is still loading.
if (sample(name, t0, v)) return;
if (SOUNDS[name]) SOUNDS[name](t0, v);
} catch (e) {
/* a sound is never worth throwing over */
}

View File

@@ -293,10 +293,10 @@
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// Confetti for a phrase guessed outright — the one call you make on your own
// rather than by grinding the alphabet.
if (v.outcome === "solved" && v.net > 0) FX.burst(verdictEl, { count: 30 });
else if (v.net > 0) FX.sfx("win");
// A phrase guessed outright keeps the confetti — the one call you make on your
// 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 }); FX.moneyRain({ count: 28 }); }
else if (v.net > 0) FX.moneyRain();
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 () {
potTotal.textContent = money(pot.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);
});

View File

@@ -44,7 +44,10 @@
var playing = root.querySelector("[data-playing]");
var betting = root.querySelector("[data-betting]");
var playControls = root.querySelector("[data-play-controls]");
var wonControls = root.querySelector("[data-won-controls]");
var autoBtn = root.querySelector("[data-auto]");
var finishBtn = root.querySelector("[data-finish]");
var cashBtn = root.querySelector("[data-cash]");
var cashAmountEl = root.querySelector("[data-cash-amount]");
var startBtn = root.querySelector("[data-start]");
@@ -258,6 +261,11 @@
playing.classList.toggle("hidden", !live);
betting.classList.toggle("hidden", live);
if (!live) return;
// A won board has nothing left to decide, so the ordinary controls step aside
// for the one button that finishes it.
var won = !!v.won;
wonControls.classList.toggle("hidden", !won);
playControls.classList.toggle("hidden", won);
autoBtn.disabled = !v.can_auto;
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
}
@@ -545,6 +553,10 @@
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
// The finish button. On a won board a single auto drains every card home in one
// cascade, and the board settles cleared on the far end of it.
finishBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
cashBtn.addEventListener("click", function () {
drop();
send({ kind: "concede" });
@@ -576,9 +588,9 @@
verdictEl.textContent = text;
verdictEl.classList.remove("hidden");
// 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.
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
else if (v.net > 0) FX.sfx("win");
// this room, so it keeps the confetti — but any win now makes it rain.
if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
else if (v.net > 0) FX.moneyRain();
else if (v.net < 0) FX.sfx("lose");
else FX.sfx("push");
}

View File

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

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":

View File

@@ -4,7 +4,7 @@
//
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
// drops every cache that doesn't match the current version.
var CACHE_VERSION = "v4";
var CACHE_VERSION = "v5";
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
@@ -117,18 +117,24 @@ self.addEventListener("fetch", function (event) {
return;
}
// Static assets: cache-first (they're versioned by deploy), fill the cache on
// first miss so a later offline visit has them.
// Static assets: network-first. Our asset URLs are NOT content-hashed
// (/static/js/weather-gl.js stays the same URL across deploys), so a
// cache-first strategy would pin whatever bytes were first cached and never
// notice a redeployed file changed — leaving stale JS/CSS in place until the
// CACHE_VERSION bump below. Go's FileServer sets ETag/Last-Modified, so an
// online refetch is a cheap 304 when nothing changed. We still fill the cache
// on every success and fall back to it when the network is unreachable, so
// offline reading keeps the shell it needs.
if (url.pathname.indexOf("/static/") === 0) {
event.respondWith(
caches.match(req).then(function (hit) {
return hit || fetch(req).then(function (res) {
fetch(req).then(function (res) {
if (res && res.ok) {
var copy = res.clone();
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
}
return res;
});
}).catch(function () {
return caches.match(req);
})
);
return;

View File

@@ -86,7 +86,9 @@
<!-- Playing: shown while a board is live. -->
<section data-playing class="hidden rounded-3xl bg-[color:var(--card)] p-5 sm:p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<div class="flex flex-wrap items-center gap-3">
<!-- The ordinary controls, while there's still a game to play. -->
<div data-play-controls class="flex flex-wrap items-center gap-3">
<button type="button" data-auto
class="rounded-full bg-[color:var(--ink)]/5 px-5 py-2.5 font-display font-bold border-2 border-[color:var(--ink)]/10
hover:bg-[color:var(--ink)]/10 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none transition">
@@ -103,6 +105,22 @@
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
</button>
</div>
<!-- The board's won: every card's face up and the piles are drained, so
there's nothing left to decide. One press sends the lot home. -->
<div data-won-controls class="hidden">
<div class="flex flex-wrap items-center gap-3">
<button type="button" data-finish
class="pete-finish rounded-full bg-[color:var(--accent)] px-8 py-3 font-display text-lg font-bold text-white shadow-pete
hover:brightness-105 active:translate-y-px disabled:pointer-events-none transition">
You've cracked it. Send them all home 🎉
</button>
<p class="text-xs text-[color:var(--ink)]/45">
The whole board's face up now, so the rest plays itself.
</p>
</div>
</div>
<p data-game-msg class="hidden mt-3 rounded-2xl bg-[color:var(--ink)]/5 px-4 py-2 text-sm font-semibold"></p>
</section>