Compare commits
7 Commits
4189c03a82
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b814f936a8 | ||
|
|
8504c4f47a | ||
|
|
e0d90ff7cc | ||
|
|
790a118273 | ||
|
|
8df2212aad | ||
|
|
1ca794ea1a | ||
|
|
30b0e8debb |
@@ -620,6 +620,31 @@ func (s State) Net() int64 {
|
|||||||
// cleared reports whether every card is home.
|
// cleared reports whether every card is home.
|
||||||
func (s State) cleared() bool { return s.Home() == FullDeck }
|
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
|
// 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
|
// finish button out rather than letting it be pressed at a board that has nothing
|
||||||
// for it.
|
// for it.
|
||||||
|
|||||||
@@ -423,6 +423,52 @@ func TestAutoSendsEverythingItCanHome(t *testing.T) {
|
|||||||
refuses(t, next, Move{Kind: "auto"}, ErrNothingHome)
|
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 money -------------------------------------------------------------
|
||||||
|
|
||||||
// The number the felt quotes while you play and the number settle() lands on are
|
// The number the felt quotes while you play and the number settle() lands on are
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type solitaireView struct {
|
|||||||
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
Passes int `json:"passes"` // through the stock, counting this one; -1 unlimited
|
||||||
Moves int `json:"moves"`
|
Moves int `json:"moves"`
|
||||||
CanAuto bool `json:"can_auto"`
|
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
|
Home int `json:"home"` // cards on the foundations
|
||||||
PerCard float64 `json:"per_card"` // what one more is worth
|
PerCard float64 `json:"per_card"` // what one more is worth
|
||||||
@@ -86,6 +87,7 @@ func viewSolitaire(g klondike.State) solitaireView {
|
|||||||
Passes: g.PassesLeft(),
|
Passes: g.PassesLeft(),
|
||||||
Moves: g.Moves,
|
Moves: g.Moves,
|
||||||
CanAuto: g.CanAuto(),
|
CanAuto: g.CanAuto(),
|
||||||
|
Won: g.Won(),
|
||||||
Home: g.Home(),
|
Home: g.Home(),
|
||||||
PerCard: g.PerCard(),
|
PerCard: g.PerCard(),
|
||||||
BreakEven: g.Tier.BreakEven(),
|
BreakEven: g.Tier.BreakEven(),
|
||||||
|
|||||||
21
internal/web/static/audio/casino/LICENSE.txt
Normal file
21
internal/web/static/audio/casino/LICENSE.txt
Normal 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
|
||||||
BIN
internal/web/static/audio/casino/cardFan1.ogg
Normal file
BIN
internal/web/static/audio/casino/cardFan1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardFan2.ogg
Normal file
BIN
internal/web/static/audio/casino/cardFan2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace1.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace2.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace3.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace3.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardPlace4.ogg
Normal file
BIN
internal/web/static/audio/casino/cardPlace4.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardShuffle.ogg
Normal file
BIN
internal/web/static/audio/casino/cardShuffle.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide1.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide2.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide3.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide3.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide4.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide4.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide5.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide5.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/cardSlide6.ogg
Normal file
BIN
internal/web/static/audio/casino/cardSlide6.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipLay1.ogg
Normal file
BIN
internal/web/static/audio/casino/chipLay1.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipLay2.ogg
Normal file
BIN
internal/web/static/audio/casino/chipLay2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipsHandle2.ogg
Normal file
BIN
internal/web/static/audio/casino/chipsHandle2.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipsHandle4.ogg
Normal file
BIN
internal/web/static/audio/casino/chipsHandle4.ogg
Normal file
Binary file not shown.
BIN
internal/web/static/audio/casino/chipsHandle6.ogg
Normal file
BIN
internal/web/static/audio/casino/chipsHandle6.ogg
Normal file
Binary file not shown.
@@ -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;
|
||||||
@@ -1444,6 +1456,14 @@ html[data-phase="night"] {
|
|||||||
100% { box-shadow: 0 0 0 1.4rem rgba(242, 181, 61, 0); }
|
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
|
/* 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
|
it's a column down the right of the felt; on a narrow one it lies down and
|
||||||
sits under the board. */
|
sits under the board. */
|
||||||
@@ -1978,6 +1998,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;
|
||||||
@@ -2031,7 +2138,8 @@ html[data-phase="night"] {
|
|||||||
.pete-tile-hit,
|
.pete-tile-hit,
|
||||||
.pete-meter[data-hit="1"] { animation: none; }
|
.pete-meter[data-hit="1"] { animation: none; }
|
||||||
.pete-nope,
|
.pete-nope,
|
||||||
.pete-home-flash { animation: none; }
|
.pete-home-flash,
|
||||||
|
.pete-finish { animation: none; }
|
||||||
.pete-card[data-held="1"] { transition: none; }
|
.pete-card[data-held="1"] { transition: none; }
|
||||||
/* The clock still drains — it is information, not decoration — but it stops
|
/* The clock still drains — it is information, not decoration — but it stops
|
||||||
pulsing at you, and a wrong answer stops shaking. */
|
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"]::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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
// The noise the room makes.
|
// The noise the room makes.
|
||||||
//
|
//
|
||||||
// There are no audio files here and there is nothing to download. Every sound in
|
// The room speaks in two voices. The cards and chips are *recorded* — real clay
|
||||||
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope —
|
// and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
|
||||||
// the same bargain the weather engine takes with its clouds. A card is a short
|
// files under /static/audio/casino. Synthesis never quite caught the grain of a
|
||||||
// slap of noise through a bandpass; a chip is two detuned sines with a click on
|
// chip landing on a chip, so for those we stopped trying. Everything melodic —
|
||||||
// the front; a win is four notes going up. It costs about six kilobytes and no
|
// the wins, the losses, the little ticks — is still *made*: an oscillator, a
|
||||||
// round trips, and it means a sound can be pitched, stretched and detuned per
|
// burst of filtered noise, an envelope, the same bargain the weather engine takes
|
||||||
// call instead of being the same wav 300 times.
|
// 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.
|
// Two rules hold the whole file up.
|
||||||
//
|
//
|
||||||
@@ -21,6 +27,10 @@
|
|||||||
// default-off switch for the entire file, checked before anything else happens.
|
// default-off switch for the entire file, checked before anything else happens.
|
||||||
//
|
//
|
||||||
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
|
// 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 () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -108,6 +118,95 @@
|
|||||||
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
|
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 ------------------------------------------------------------
|
// ---- the sounds ------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Each one takes the time it starts at, and a `v` — a small per-call variation,
|
// 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.
|
// 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 });
|
||||||
@@ -208,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 --------------------------------------------------------------
|
||||||
@@ -217,6 +364,7 @@
|
|||||||
// gesture — after which play() can schedule freely.
|
// gesture — after which play() can schedule freely.
|
||||||
function wake() {
|
function wake() {
|
||||||
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
||||||
|
warm();
|
||||||
}
|
}
|
||||||
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
||||||
window.addEventListener(ev, wake, { passive: true });
|
window.addEventListener(ev, wake, { passive: true });
|
||||||
@@ -231,13 +379,18 @@
|
|||||||
// lands in 400ms can say so rather than sleeping.
|
// lands in 400ms can say so rather than sleeping.
|
||||||
function play(name, opts) {
|
function play(name, opts) {
|
||||||
if (muted) return;
|
if (muted) return;
|
||||||
var s = SOUNDS[name];
|
if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
|
||||||
if (!s) return;
|
|
||||||
if (!boot()) return;
|
if (!boot()) return;
|
||||||
wake();
|
wake();
|
||||||
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
|
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 {
|
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) {
|
} catch (e) {
|
||||||
/* a sound is never worth throwing over */
|
/* a sound is never worth throwing over */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@
|
|||||||
|
|
||||||
var playing = root.querySelector("[data-playing]");
|
var playing = root.querySelector("[data-playing]");
|
||||||
var betting = root.querySelector("[data-betting]");
|
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 autoBtn = root.querySelector("[data-auto]");
|
||||||
|
var finishBtn = root.querySelector("[data-finish]");
|
||||||
var cashBtn = root.querySelector("[data-cash]");
|
var cashBtn = root.querySelector("[data-cash]");
|
||||||
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
||||||
var startBtn = root.querySelector("[data-start]");
|
var startBtn = root.querySelector("[data-start]");
|
||||||
@@ -258,6 +261,11 @@
|
|||||||
playing.classList.toggle("hidden", !live);
|
playing.classList.toggle("hidden", !live);
|
||||||
betting.classList.toggle("hidden", live);
|
betting.classList.toggle("hidden", live);
|
||||||
if (!live) return;
|
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;
|
autoBtn.disabled = !v.can_auto;
|
||||||
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -545,6 +553,10 @@
|
|||||||
|
|
||||||
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
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 () {
|
cashBtn.addEventListener("click", function () {
|
||||||
drop();
|
drop();
|
||||||
send({ kind: "concede" });
|
send({ kind: "concede" });
|
||||||
@@ -576,9 +588,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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
// Bump CACHE_VERSION whenever the precached shell assets change; activate()
|
||||||
// drops every cache that doesn't match the current version.
|
// 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 SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
|
||||||
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
|
||||||
|
|
||||||
@@ -117,18 +117,24 @@ self.addEventListener("fetch", function (event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets: cache-first (they're versioned by deploy), fill the cache on
|
// Static assets: network-first. Our asset URLs are NOT content-hashed
|
||||||
// first miss so a later offline visit has them.
|
// (/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) {
|
if (url.pathname.indexOf("/static/") === 0) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(req).then(function (hit) {
|
fetch(req).then(function (res) {
|
||||||
return hit || fetch(req).then(function (res) {
|
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
var copy = res.clone();
|
var copy = res.clone();
|
||||||
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
caches.open(SHELL_CACHE).then(function (cache) { cache.put(req, copy); });
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
});
|
}).catch(function () {
|
||||||
|
return caches.match(req);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -86,7 +86,9 @@
|
|||||||
|
|
||||||
<!-- Playing: shown while a board is live. -->
|
<!-- 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">
|
<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
|
<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
|
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">
|
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>
|
Cash the board · <span data-cash-amount class="tabular-nums">0</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<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>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user