games: the word you owe the table, and the hand you were already holding
Three things, and the first one was a bug. Your own hand didn't move until the lap ended. bump() keeps the bots' fans honest and has always refused seat zero, and nothing else touched yours — so a +4 landing on you at the top of a lap put four backs into your hand and then nothing, and the cards themselves turned up seconds later when the script finished and paint() finally ran. You spent the whole lap looking at a hand you no longer held. The engine now stamps your hand onto every event that changes it (Event.Hand, seat zero only, which is the one hand the browser is already entitled to see) and the table redraws as the cards land. Measured in the running app: 2 -> 3 cards at 414ms into a 1791ms lap. You couldn't call UNO, and not because the button was missing: going down to one card *was* the call. discard() fired the uno event by itself, which made it a thing that happened to you rather than a thing you did, and a rule nobody can fail is not a rule. So now you say it or you don't (Move.Uno), and if you don't, every bot still in the game gets one look at you before any of them plays — because a bot that has moved on is a bot that has stopped watching your hand. It runs the other way too, and that half is the fun one: a bot forgets often enough to be worth watching for, and when it does it says *nothing*. No event, no badge, no tell on the felt except the count beside its fan reading "1 card". Catch it and it takes two; call a seat that had nothing to hide and you take two yourself, which is what stops the catch button from being a thing you simply mash. Which cards owe the call is the engine's answer, not a count of your hand: No Mercy's "discard all" takes every card of its colour with it, so a six-card hand can land on one, and a browser subtracting one from six walks you into a catch it never warned you about. And the room was silent. Every sound in here 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 slap of noise through a bandpass, a chip is two detuned sines with a knock on the front, a win is four notes going up. No asset files, no round trips, and a sound can be pitched and detuned per call instead of being the same wav three hundred times. Hooked into the FX layer rather than into the games, so every table that throws a chip or turns a card got it at once. The multiples moved, and the test that exists to catch that caught it. The naive strategy now calls UNO, because calling is a button and not a strategy — what these tiers price is bad card play, not a player who ignores the felt shouting at them — and on that footing the normal tables come back to where they were (40.1 / 28.5 / 23.1). No Mercy Full House did not: it was paying a *negative* house edge, which is the house paying you to sit down. Re-priced 3.8 -> 3.5. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -1563,6 +1563,10 @@ html[data-phase="night"] {
|
||||
animation-delay: calc(var(--i, 0) * 45ms);
|
||||
transition: transform 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
/* A card that was already in your hand before this redraw doesn't get dealt to
|
||||
you again. The hand is redrawn several times a lap now (every event that
|
||||
changes it), and without this every one of them re-fans the whole thing. */
|
||||
.pete-uno-hand .pete-uno-card[data-settled="1"] { animation: none; }
|
||||
.pete-uno-hand .pete-uno-card[data-on="1"] {
|
||||
cursor: pointer;
|
||||
transform: translateY(-0.5rem);
|
||||
@@ -1750,6 +1754,85 @@ html[data-phase="night"] {
|
||||
.pete-uno-swatch[data-c="yellow"] { --sw: #e0b02c; color: #2b2118; }
|
||||
.pete-uno-swatch[data-c="green"] { --sw: #46a86b; }
|
||||
|
||||
/* ---- calling it, and catching the seat that didn't -------------------------
|
||||
The call is a button with a clock on it. It wants to be the loudest thing on
|
||||
the felt for the two-and-a-bit seconds it exists, because the whole rule is
|
||||
that you can miss it. */
|
||||
.pete-uno-call {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.9rem 2.6rem;
|
||||
border-radius: 999px;
|
||||
background: #e0b02c;
|
||||
color: #2b2118;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0 rgba(224,176,44,0.55);
|
||||
animation: pete-uno-call 0.9s ease-in-out infinite;
|
||||
}
|
||||
.pete-uno-call:hover { filter: brightness(1.07); }
|
||||
.pete-uno-call:active { transform: translateY(2px); box-shadow: 0 3px 0 rgba(0,0,0,0.3); }
|
||||
@keyframes pete-uno-call {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0 rgba(224,176,44,0.5); }
|
||||
50% { transform: scale(1.05); box-shadow: 0 5px 0 rgba(0,0,0,0.3), 0 0 0 0.9rem rgba(224,176,44,0); }
|
||||
}
|
||||
|
||||
/* How long you've got, draining left to right. The duration is set on the
|
||||
element: the window lives in uno.js, and a bar that disagrees with the clock
|
||||
it's drawing is worse than no bar. */
|
||||
.pete-uno-timer {
|
||||
margin-top: 0.8rem;
|
||||
height: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pete-uno-timer > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #e0b02c;
|
||||
transform-origin: left center;
|
||||
animation: pete-uno-drain linear forwards;
|
||||
}
|
||||
@keyframes pete-uno-drain {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
|
||||
/* A seat on one card that never said so. It gets a button, and the button is
|
||||
the only thing on the felt that says it — there's no badge, because not
|
||||
announcing is exactly what it did. */
|
||||
.pete-uno-catch {
|
||||
position: absolute;
|
||||
top: -0.55rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 6;
|
||||
padding: 0.22rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
background: #cc3d4a;
|
||||
color: #fff;
|
||||
font-family: "Fredoka", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,0.25);
|
||||
animation: pete-uno-catch 1.1s ease-in-out infinite;
|
||||
transition: filter 0.12s ease;
|
||||
}
|
||||
.pete-uno-catch:hover { filter: brightness(1.12); }
|
||||
.pete-uno-catch:disabled { opacity: 0.45; animation: none; }
|
||||
@keyframes pete-uno-catch {
|
||||
0%, 100% { transform: translateX(-50%) scale(1); }
|
||||
50% { transform: translateX(-50%) scale(1.08); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pete-uno-call, .pete-uno-catch { animation: none; }
|
||||
}
|
||||
|
||||
/* ---- No Mercy -------------------------------------------------------------
|
||||
A rules dial, not a fourth table, and the felt says so: the same table, the
|
||||
same cards, plus six faces the normal box doesn't print and one thing that
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -164,7 +164,13 @@
|
||||
|
||||
// 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");
|
||||
else if (v.net < 0) FX.sfx("lose");
|
||||
else FX.sfx("push");
|
||||
}
|
||||
|
||||
// setPhase swaps the controls: bet between hands, act during one.
|
||||
@@ -221,10 +227,12 @@
|
||||
playerEl.innerHTML = "";
|
||||
playerEl.dataset.won = "0";
|
||||
verdictEl.classList.add("hidden");
|
||||
FX.sfx("shuffle");
|
||||
return;
|
||||
|
||||
case "player_card":
|
||||
playerEl.appendChild(cardEl(e.card));
|
||||
FX.sfx("deal");
|
||||
return wait(DEAL_MS);
|
||||
|
||||
case "dealer_card":
|
||||
@@ -235,12 +243,14 @@
|
||||
drew = true;
|
||||
return beat.then(function () {
|
||||
dealerEl.appendChild(cardEl(e.card));
|
||||
FX.sfx("deal", { v: 1 });
|
||||
return wait(DEAL_MS);
|
||||
});
|
||||
|
||||
case "dealer_hole":
|
||||
hole = cardEl(null);
|
||||
dealerEl.appendChild(hole);
|
||||
FX.sfx("deal", { v: 2 });
|
||||
return wait(DEAL_MS);
|
||||
|
||||
case "reveal":
|
||||
|
||||
@@ -155,11 +155,17 @@
|
||||
}
|
||||
|
||||
// turnOver flips a face-down card up, now that we've been told what it is.
|
||||
//
|
||||
// Unlike el(), this is only ever a *gesture* — a card that was down and is now
|
||||
// up, because something happened. It never runs on a repaint, which is why the
|
||||
// flip sound can live here and be right for every table at once, and why the
|
||||
// deal sound cannot: el() runs every time a board is redrawn.
|
||||
function turnOver(wrap, face) {
|
||||
if (!wrap) return;
|
||||
paint(wrap.querySelector(".pete-card-front"), face);
|
||||
wrap.dataset.face = "up";
|
||||
wrap.dataset.key = face.label;
|
||||
if (window.PeteSFX) window.PeteSFX.play("flip");
|
||||
}
|
||||
|
||||
window.PeteCards = {
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
var reduced =
|
||||
window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
// The room's noise, if it's loaded. Everything that flies through this file
|
||||
// makes a sound when it lands, which is how every table got sound at once
|
||||
// without any of them being told about it: a chip is a chip wherever it's
|
||||
// thrown from. A game that wants something more specific says so per call.
|
||||
function sfx(name, opts) {
|
||||
if (window.PeteSFX) window.PeteSFX.play(name, opts);
|
||||
}
|
||||
|
||||
var layer = null;
|
||||
function stage() {
|
||||
if (!layer) {
|
||||
@@ -107,13 +115,23 @@
|
||||
// than averaging itself back down to nothing.
|
||||
var sMid = Math.max(s0, s1) * 1.12;
|
||||
|
||||
var wait = reduced ? 0 : opts.delay || 0;
|
||||
|
||||
// It makes a noise when it lands, and the noise is scheduled on the audio
|
||||
// clock for the moment it lands — not fired by a timer that goes off when the
|
||||
// animation ends. A timer that drifts thirty milliseconds is a card you hear
|
||||
// before you see, and that is worse than silence.
|
||||
if (opts.sound) {
|
||||
sfx(opts.sound, { delay: (wait + dur) / 1000, v: opts.index || 0 });
|
||||
}
|
||||
|
||||
var anim = node.animate(
|
||||
[
|
||||
{ transform: t(a.x, a.y, s0, opts.fromSpin || 0), opacity: 1, offset: 0 },
|
||||
{ transform: t(midX, midY, sMid, spin * 0.6), opacity: 1, offset: 0.5 },
|
||||
{ transform: t(b.x, b.y, s1, spin), opacity: opts.fade ? 0 : 1, offset: 1 },
|
||||
],
|
||||
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: reduced ? 0 : opts.delay || 0, fill: "both" }
|
||||
{ duration: dur, easing: "cubic-bezier(0.33, 0, 0.25, 1)", delay: wait, fill: "both" }
|
||||
);
|
||||
|
||||
return anim.finished
|
||||
@@ -127,6 +145,9 @@
|
||||
// was — UNO wanted the same throw with a card in it, so the throw moved out.
|
||||
function fly(from, to, opts) {
|
||||
opts = opts || {};
|
||||
// A chip clinks. Every table in the room bets, pays and sweeps through this
|
||||
// one function, so saying it once here is every table having chip sounds.
|
||||
if (opts.sound === undefined) opts = Object.assign({ sound: "chip" }, opts);
|
||||
return flyNode(disc(opts.denom || 25), from, to, opts);
|
||||
}
|
||||
|
||||
@@ -225,6 +246,7 @@
|
||||
var n = amount == null ? api.amount : amount;
|
||||
var left = api.amount - n;
|
||||
if (n <= 0) return Promise.resolve();
|
||||
sfx("sweep"); // the pile leaving the felt, under the chips landing
|
||||
var chain = flyMany(els.spot, to, chipsFor(n, 8), Object.assign({ gap: 40, lift: 0.8 }, opts || {}));
|
||||
api.render(left > 0 ? left : 0);
|
||||
return chain;
|
||||
@@ -233,8 +255,10 @@
|
||||
return api;
|
||||
}
|
||||
|
||||
// burst: confetti out of a point. Saved for the things worth celebrating.
|
||||
// burst: confetti out of a point. Saved for the things worth celebrating — so
|
||||
// it is also, everywhere in the room, the sound of winning.
|
||||
function burst(target, opts) {
|
||||
sfx("win"); // before the reduced-motion bail: no confetti still deserves the fanfare
|
||||
if (reduced) return Promise.resolve();
|
||||
opts = opts || {};
|
||||
var c = centre(target);
|
||||
@@ -326,6 +350,7 @@
|
||||
|
||||
window.PeteFX = {
|
||||
reduced: reduced,
|
||||
sfx: sfx, // so a table can make a noise without reaching past this file
|
||||
chipsFor: chipsFor,
|
||||
disc: disc,
|
||||
jitter: jitter,
|
||||
|
||||
265
internal/web/static/js/casino-sfx.js
Normal file
265
internal/web/static/js/casino-sfx.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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.
|
||||
//
|
||||
// Two rules hold the whole file up.
|
||||
//
|
||||
// **Nothing is built until it's asked for.** A browser will not let a page make a
|
||||
// noise before the user has touched it, and a page that builds an AudioContext on
|
||||
// load gets one in the "suspended" state and a warning in the console for its
|
||||
// trouble. So the context is made on the first sound anybody actually asks for —
|
||||
// which, in a casino, is a click on a chip.
|
||||
//
|
||||
// **Muted means silent, not quiet.** When the room is muted nothing is
|
||||
// constructed, nothing is scheduled and no context is opened. Mute is the
|
||||
// default-off switch for the entire file, checked before anything else happens.
|
||||
//
|
||||
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var KEY = "pete.sfx.off";
|
||||
|
||||
var ctx = null;
|
||||
var master = null;
|
||||
var noiseBuf = null;
|
||||
var muted = false;
|
||||
var listeners = [];
|
||||
|
||||
try { muted = localStorage.getItem(KEY) === "1"; } catch (e) {}
|
||||
|
||||
// ---- the instrument --------------------------------------------------------
|
||||
|
||||
function boot() {
|
||||
if (ctx) return ctx;
|
||||
var AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
try {
|
||||
ctx = new AC();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
master = ctx.createGain();
|
||||
master.gain.value = 0.45; // the room is furniture, not a nightclub
|
||||
master.connect(ctx.destination);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// A second of white noise, made once and played at different speeds and through
|
||||
// different filters for the rest of the session. Card flicks, riffles, the
|
||||
// transient on a chip: all of them are this buffer wearing a different coat.
|
||||
function noise() {
|
||||
if (noiseBuf) return noiseBuf;
|
||||
var n = ctx.sampleRate;
|
||||
noiseBuf = ctx.createBuffer(1, n, n);
|
||||
var d = noiseBuf.getChannelData(0);
|
||||
for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
|
||||
return noiseBuf;
|
||||
}
|
||||
|
||||
// env is the shape of every sound in here: up fast, down slow, and never to
|
||||
// zero — an exponential ramp to actual zero is undefined, and a browser that
|
||||
// hits one either throws or clicks.
|
||||
function env(g, t0, attack, decay, peak) {
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + attack);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay);
|
||||
}
|
||||
|
||||
// tone is one note.
|
||||
function tone(t0, freq, o) {
|
||||
o = o || {};
|
||||
var osc = ctx.createOscillator();
|
||||
var g = ctx.createGain();
|
||||
osc.type = o.type || "sine";
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.glide || o.decay || 0.1));
|
||||
env(g, t0, o.attack || 0.004, o.decay || 0.12, o.gain == null ? 0.3 : o.gain);
|
||||
osc.connect(g).connect(master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + (o.attack || 0.004) + (o.decay || 0.12) + 0.02);
|
||||
}
|
||||
|
||||
// hiss is a burst of the noise buffer through a filter, which is what a card,
|
||||
// a riffle and the knock on a chip all are.
|
||||
function hiss(t0, o) {
|
||||
o = o || {};
|
||||
var src = ctx.createBufferSource();
|
||||
src.buffer = noise();
|
||||
src.playbackRate.value = o.rate || 1;
|
||||
|
||||
var f = ctx.createBiquadFilter();
|
||||
f.type = o.filter || "bandpass";
|
||||
f.frequency.setValueAtTime(o.freq || 2000, t0);
|
||||
if (o.sweepTo) f.frequency.exponentialRampToValueAtTime(o.sweepTo, t0 + (o.decay || 0.1));
|
||||
f.Q.value = o.q == null ? 1.1 : o.q;
|
||||
|
||||
var g = ctx.createGain();
|
||||
env(g, t0, o.attack || 0.003, o.decay || 0.09, o.gain == null ? 0.3 : o.gain);
|
||||
|
||||
src.connect(f).connect(g).connect(master);
|
||||
src.start(t0);
|
||||
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02);
|
||||
}
|
||||
|
||||
// ---- the sounds ------------------------------------------------------------
|
||||
//
|
||||
// Each one takes the time it starts at, and a `v` — a small per-call variation,
|
||||
// usually the index of the card or chip in a run. Four cards dealt with the
|
||||
// identical sample four times is a machine gun; four cards each a semitone off
|
||||
// is a hand dealing.
|
||||
|
||||
var SOUNDS = {
|
||||
// A card landing: mostly air, with a slap on the front of it.
|
||||
card: function (t, v) {
|
||||
hiss(t, { freq: 1800 + v * 140, q: 0.9, decay: 0.075, rate: 1 + v * 0.05, gain: 0.3 });
|
||||
hiss(t, { filter: "highpass", freq: 4200, decay: 0.02, gain: 0.16 });
|
||||
},
|
||||
|
||||
// Dealing is the same card, lower and softer: it's being placed, not thrown.
|
||||
deal: function (t, v) {
|
||||
hiss(t, { freq: 1250 + v * 110, q: 1.1, decay: 0.085, rate: 0.9, gain: 0.24 });
|
||||
},
|
||||
|
||||
// A card turning over — shorter, brighter, and it comes in two halves, which
|
||||
// is what a flip actually sounds like.
|
||||
flip: function (t) {
|
||||
hiss(t, { freq: 2600, q: 1.4, decay: 0.035, gain: 0.22 });
|
||||
hiss(t + 0.045, { freq: 1500, q: 1.0, decay: 0.06, gain: 0.24 });
|
||||
},
|
||||
|
||||
// The riffle. One long sweep of noise with the filter climbing through it,
|
||||
// and a rattle laid over the top so it isn't just a shhh.
|
||||
shuffle: function (t) {
|
||||
hiss(t, { freq: 700, sweepTo: 3400, q: 0.7, attack: 0.02, decay: 0.34, gain: 0.2 });
|
||||
for (var i = 0; i < 9; i++) {
|
||||
hiss(t + 0.03 + i * 0.032, { freq: 2400 + i * 130, q: 2.2, decay: 0.02, gain: 0.09 });
|
||||
}
|
||||
},
|
||||
|
||||
// A chip landing on a chip. Two detuned partials well above the staff, with a
|
||||
// knock underneath — the knock is most of what makes it read as *clay* rather
|
||||
// than as a bell.
|
||||
chip: function (t, v) {
|
||||
var f = 1750 + v * 55;
|
||||
tone(t, f, { type: "sine", decay: 0.07, gain: 0.16 });
|
||||
tone(t, f * 1.34, { type: "sine", decay: 0.05, gain: 0.1 });
|
||||
hiss(t, { filter: "lowpass", freq: 900, decay: 0.03, gain: 0.3, q: 0.5 });
|
||||
},
|
||||
|
||||
// Chips sliding away across felt.
|
||||
sweep: function (t) {
|
||||
hiss(t, { freq: 1600, sweepTo: 500, q: 0.6, attack: 0.015, decay: 0.26, gain: 0.16 });
|
||||
},
|
||||
|
||||
// Four notes up. This is the only sound in the room allowed to be pleased.
|
||||
win: function (t) {
|
||||
[523.25, 659.25, 783.99, 1046.5].forEach(function (f, i) {
|
||||
tone(t + i * 0.085, f, { type: "triangle", decay: 0.26, gain: 0.24 });
|
||||
});
|
||||
},
|
||||
|
||||
// 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 });
|
||||
tone(t + 0.15, 233.08, { type: "triangle", decay: 0.42, gain: 0.22 });
|
||||
},
|
||||
|
||||
// Nothing happened and nothing was lost.
|
||||
push: function (t) {
|
||||
tone(t, 392, { type: "sine", decay: 0.2, gain: 0.16 });
|
||||
},
|
||||
|
||||
// A button. Short enough that you feel it rather than hear it.
|
||||
blip: function (t) {
|
||||
tone(t, 880, { type: "sine", decay: 0.055, gain: 0.12 });
|
||||
},
|
||||
|
||||
// UNO, called. A bright stab plus a zip upwards: it is a shout, and it is the
|
||||
// one thing on that table you want to feel good about pressing.
|
||||
uno: function (t) {
|
||||
[659.25, 830.61, 987.77].forEach(function (f) {
|
||||
tone(t, f, { type: "triangle", decay: 0.3, gain: 0.2 });
|
||||
});
|
||||
tone(t, 500, { type: "sawtooth", to: 1600, glide: 0.16, decay: 0.18, gain: 0.09 });
|
||||
},
|
||||
|
||||
// Got them. A rising snap with a thump on the end — the sound of a finger
|
||||
// being pointed.
|
||||
catch: function (t) {
|
||||
tone(t, 300, { type: "square", to: 900, glide: 0.11, decay: 0.12, gain: 0.13 });
|
||||
hiss(t + 0.1, { filter: "lowpass", freq: 1100, decay: 0.1, gain: 0.34, q: 0.7 });
|
||||
tone(t + 0.1, 174.61, { type: "triangle", decay: 0.22, gain: 0.2 });
|
||||
},
|
||||
|
||||
// Something bad landed on you: a stack, a +4, a catch you walked into.
|
||||
bad: function (t) {
|
||||
tone(t, 155.56, { type: "square", to: 98, glide: 0.18, decay: 0.22, gain: 0.14 });
|
||||
hiss(t, { filter: "lowpass", freq: 700, decay: 0.14, gain: 0.3, q: 0.6 });
|
||||
},
|
||||
|
||||
// A clock you can hear. Used sparingly, and never in a run of more than a few.
|
||||
tick: function (t) {
|
||||
hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 });
|
||||
},
|
||||
};
|
||||
|
||||
// ---- the door --------------------------------------------------------------
|
||||
|
||||
// A browser will not make a noise until the user has touched the page, and a
|
||||
// context built before that arrives suspended. This wakes it on the first real
|
||||
// gesture — after which play() can schedule freely.
|
||||
function wake() {
|
||||
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
|
||||
}
|
||||
["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
|
||||
window.addEventListener(ev, wake, { passive: true });
|
||||
});
|
||||
|
||||
// play makes a sound, or — if the room is muted — makes nothing at all, opens no
|
||||
// context and builds no graph.
|
||||
//
|
||||
// `v` is the variation: pass the index of the card or chip in a run and the
|
||||
// sound moves a little each time, which is the difference between a hand and a
|
||||
// machine. `delay` schedules it ahead in seconds, so a caller that knows a card
|
||||
// lands in 400ms can say so rather than sleeping.
|
||||
function play(name, opts) {
|
||||
if (muted) return;
|
||||
var s = SOUNDS[name];
|
||||
if (!s) return;
|
||||
if (!boot()) return;
|
||||
wake();
|
||||
if (ctx.state !== "running") return; // not yet touched: no sound, and no error
|
||||
try {
|
||||
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0));
|
||||
} catch (e) {
|
||||
/* a sound is never worth throwing over */
|
||||
}
|
||||
}
|
||||
|
||||
function setMuted(on) {
|
||||
muted = !!on;
|
||||
try {
|
||||
if (muted) localStorage.setItem(KEY, "1");
|
||||
else localStorage.removeItem(KEY);
|
||||
} catch (e) {}
|
||||
if (window.PetePrefs) window.PetePrefs.push();
|
||||
listeners.forEach(function (fn) { fn(muted); });
|
||||
if (!muted) play("blip"); // so you know what you just turned back on
|
||||
}
|
||||
|
||||
window.PeteSFX = {
|
||||
play: play,
|
||||
muted: function () { return muted; },
|
||||
toggle: function () { setMuted(!muted); return muted; },
|
||||
set: setMuted,
|
||||
// onChange lets the mute button re-label itself without owning the state.
|
||||
onChange: function (fn) { listeners.push(fn); fn(muted); },
|
||||
};
|
||||
})();
|
||||
@@ -154,6 +154,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
// The room's volume. The button lives in the chip bar, which is the one thing
|
||||
// every table has — the sound belongs to the room, not to any one game — and
|
||||
// the state itself belongs to PeteSFX, which persists it. This only draws it.
|
||||
var sfxBtn = document.querySelector("[data-sfx-toggle]");
|
||||
if (sfxBtn && window.PeteSFX) {
|
||||
var onIcon = sfxBtn.querySelector("[data-sfx-on]");
|
||||
var offIcon = sfxBtn.querySelector("[data-sfx-off]");
|
||||
var sfxLbl = sfxBtn.querySelector("[data-sfx-label]");
|
||||
|
||||
window.PeteSFX.onChange(function (muted) {
|
||||
sfxBtn.setAttribute("aria-pressed", muted ? "true" : "false");
|
||||
sfxBtn.title = muted ? "Sound is off" : "Sound is on";
|
||||
if (onIcon) onIcon.classList.toggle("hidden", muted);
|
||||
if (offIcon) offIcon.classList.toggle("hidden", !muted);
|
||||
if (sfxLbl) sfxLbl.textContent = muted ? "Turn the sound on" : "Mute the room";
|
||||
});
|
||||
sfxBtn.addEventListener("click", function () { window.PeteSFX.toggle(); });
|
||||
}
|
||||
|
||||
window.PeteGames = {
|
||||
// onUpdate registers a listener called on every fresh view of the money.
|
||||
onUpdate: function (fn) { listeners.push(fn); if (view) fn(view); },
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
at.forEach(function (i, n) {
|
||||
var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
|
||||
if (!t) return;
|
||||
// One note per tile, climbing: a letter that turns up three times should
|
||||
// sound like it's worth three times as much, because it is.
|
||||
FX.sfx("tick", { delay: pace(n * 90) / 1000, v: n });
|
||||
setTimeout(function () {
|
||||
// Left as it comes: the tile is uppercased in CSS, and doing it here too
|
||||
// would mean the resume path (which paints the phrase's own casing) and
|
||||
@@ -126,6 +129,7 @@
|
||||
t.classList.add("pete-tile-hit");
|
||||
}, pace(n * 90));
|
||||
});
|
||||
FX.sfx("flip");
|
||||
return wait(FLIP_MS + (at.length - 1) * 90);
|
||||
}
|
||||
|
||||
@@ -292,6 +296,8 @@
|
||||
// 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");
|
||||
else if (v.net < 0) FX.sfx("lose");
|
||||
}
|
||||
|
||||
function setPhase(v) {
|
||||
@@ -362,6 +368,7 @@
|
||||
// they are one event: this is what a wrong guess costs, all of it.
|
||||
drawGallows(countMisses(events, e), true);
|
||||
shake();
|
||||
FX.sfx("bad");
|
||||
if (final) knock(final);
|
||||
return wait(MISS_MS);
|
||||
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
pot.render(0);
|
||||
potTotal.textContent = "0";
|
||||
sideEl.classList.add("hidden");
|
||||
FX.sfx("shuffle");
|
||||
return wait(140);
|
||||
|
||||
case "rebuy":
|
||||
@@ -377,6 +378,7 @@
|
||||
var face = (i === 0 && s.cards) ? s.cards[round] : null;
|
||||
var card = Cards.el(face, { deal: true, tilt: i !== 0 });
|
||||
built.cards.appendChild(card);
|
||||
FX.sfx("deal", { delay: 0.07 * i, v: i });
|
||||
beats.push(wait(70 * i));
|
||||
});
|
||||
return Promise.all(beats).then(function () { return wait(180); });
|
||||
@@ -394,10 +396,12 @@
|
||||
if (e.text === "fold") {
|
||||
s.root.dataset.state = "folded";
|
||||
s.cards.dataset.mucked = "1";
|
||||
FX.sfx("card", { v: 2 }); // cards going into the muck
|
||||
return wait(320);
|
||||
}
|
||||
if (e.text === "check") {
|
||||
flash(s.root);
|
||||
FX.sfx("blip"); // a knuckle on the table
|
||||
return wait(320);
|
||||
}
|
||||
// call, raise, allin: chips leave their stack for their spot.
|
||||
@@ -437,9 +441,10 @@
|
||||
// The board turns one card at a time, even the flop. Three cards appearing
|
||||
// at once is a screenshot; three cards appearing in a row is a flop.
|
||||
var chain = Promise.resolve();
|
||||
(e.cards || []).forEach(function (c) {
|
||||
(e.cards || []).forEach(function (c, i) {
|
||||
chain = chain.then(function () {
|
||||
boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
|
||||
FX.sfx("card", { v: i });
|
||||
return wait(240);
|
||||
});
|
||||
});
|
||||
@@ -478,12 +483,15 @@
|
||||
});
|
||||
if (busted) {
|
||||
show("You're out of chips. Sit down again when you're ready.", "lose");
|
||||
FX.sfx("lose");
|
||||
return;
|
||||
}
|
||||
if (!events.some(function (e) { return e.kind === "end"; })) return;
|
||||
|
||||
var me = final.seats[0];
|
||||
if (won > 0) {
|
||||
// The pot coming your way already burst (and so already cheered) back in
|
||||
// the "win" event. This is only the words.
|
||||
show(showed
|
||||
? "You win " + money(won) + " with " + article(handName(events)) + "."
|
||||
: "They folded. You take " + money(won) + ".", "win");
|
||||
@@ -491,6 +499,7 @@
|
||||
show("Folded.", "lose");
|
||||
} else {
|
||||
show("No good this time.", "lose");
|
||||
FX.sfx("lose");
|
||||
}
|
||||
|
||||
function show(text, tone) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
(function () {
|
||||
// The localStorage keys we sync. The weather *cache* is deliberately excluded:
|
||||
// it's transient and per-device.
|
||||
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off"];
|
||||
var SYNCED = ["pete.disabledSources.v1", "pete.weather.loc.v1", "pete-weather-off", "pete.sfx.off"];
|
||||
|
||||
var user = window.PETE_USER || null;
|
||||
var serverPrefs = window.PETE_PREFS || null;
|
||||
|
||||
@@ -396,6 +396,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
// noises is the board's soundtrack, walked off the *same* STEP_MS ladder the
|
||||
// animation is walked off. That matters more than which sound goes where: a
|
||||
// card you hear land a step before it lands is worse than a card that lands in
|
||||
// silence, so this counts its way through the script exactly as planOf and
|
||||
// flashHome do, and any change to their timing has to be made here too.
|
||||
function noises(events) {
|
||||
var at = 0;
|
||||
(events || []).forEach(function (e) {
|
||||
switch (e.kind) {
|
||||
case "draw":
|
||||
FX.sfx("card", { delay: at / 1000 });
|
||||
break;
|
||||
case "recycle":
|
||||
FX.sfx("shuffle", { delay: at / 1000 });
|
||||
break;
|
||||
case "move":
|
||||
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
|
||||
at += STEP_MS;
|
||||
break;
|
||||
case "home":
|
||||
// A card reaching a foundation is the only move that pays, so it is the
|
||||
// only one that gets a note on the end of it.
|
||||
FX.sfx("deal", { delay: (at + MOVE_MS) / 1000 });
|
||||
FX.sfx("blip", { delay: (at + MOVE_MS + 60) / 1000 });
|
||||
at += STEP_MS;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the money -------------------------------------------------------------
|
||||
|
||||
// bank moves the spot to what the server says the board is worth. Up is chips
|
||||
@@ -548,6 +578,9 @@
|
||||
// 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");
|
||||
else if (v.net < 0) FX.sfx("lose");
|
||||
else FX.sfx("push");
|
||||
}
|
||||
|
||||
// play walks a server response onto the felt: the board is re-rendered, the
|
||||
@@ -570,6 +603,7 @@
|
||||
render(v);
|
||||
var plan = planOf(events);
|
||||
flashHome(events);
|
||||
noises(events);
|
||||
return animate(before, plan);
|
||||
})
|
||||
.then(function () {
|
||||
|
||||
@@ -372,10 +372,16 @@
|
||||
renderQuestion(final);
|
||||
renderLadder(final);
|
||||
if (final && final.phase === "playing") startClock(final.left, final.limit);
|
||||
FX.sfx("blip");
|
||||
return;
|
||||
|
||||
case "right":
|
||||
reveal(e.choice, e.correct);
|
||||
// Three notes up, one per rung climbed — the ladder, in sound.
|
||||
[0, 0.09, 0.18].forEach(function (d, i) {
|
||||
FX.sfx("tick", { delay: d, v: i });
|
||||
});
|
||||
FX.sfx("win", { delay: 0.1 });
|
||||
if (final) {
|
||||
// The rung lighting and the multiple climbing are one event,
|
||||
// because they are one event: this is what the answer was worth.
|
||||
@@ -387,10 +393,13 @@
|
||||
|
||||
case "wrong":
|
||||
reveal(e.choice, e.correct);
|
||||
FX.sfx("bad");
|
||||
return wait(1100);
|
||||
|
||||
case "timeout":
|
||||
// The clock beat you to it, which is a different kind of bad.
|
||||
reveal(-1, e.correct);
|
||||
FX.sfx("lose");
|
||||
return wait(1100);
|
||||
|
||||
case "settle":
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
var tableEl = root.querySelector("[data-table-name]");
|
||||
|
||||
var wildEl = root.querySelector("[data-wild]");
|
||||
var gateEl = root.querySelector("[data-unogate]");
|
||||
var callBtn = root.querySelector("[data-uno-call]");
|
||||
var timerEl = root.querySelector("[data-uno-timer]");
|
||||
// Not [data-pending]: that is the chip bar's, it lives inside this root, and it
|
||||
// comes first in the document. See the note in uno.html.
|
||||
var billEl = root.querySelector("[data-bill]");
|
||||
@@ -72,6 +75,14 @@
|
||||
var played = -1; // the card you just played, so the script lifts that one out
|
||||
// of the hand and not merely the first one that lit up
|
||||
|
||||
// The UNO call. CALL_MS is how long you get to say it once the card is on its
|
||||
// way down — long enough that nobody playing the game misses it, short enough
|
||||
// that it is still a thing you have to *do*. The engine has the other half of
|
||||
// this rule (see call.go); this is only the window.
|
||||
var CALL_MS = 2600;
|
||||
var gate = null; // the play waiting on a call: {index, color}
|
||||
var gateTimer = 0;
|
||||
|
||||
var reduced = FX.reduced;
|
||||
function pace(ms) { return reduced ? 0 : ms; }
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); }
|
||||
@@ -207,6 +218,9 @@
|
||||
function renderSeats(v) {
|
||||
seatsEl.innerHTML = "";
|
||||
if (!v) return;
|
||||
var catchable = {};
|
||||
(v.catchable || []).forEach(function (i) { catchable[i] = true; });
|
||||
|
||||
v.seats.forEach(function (s, i) {
|
||||
if (s.you) return; // you are the hand at the bottom, not a seat up here
|
||||
var el = document.createElement("div");
|
||||
@@ -216,6 +230,24 @@
|
||||
if (s.uno) el.dataset.uno = "1";
|
||||
if (s.out) el.dataset.out = "1"; // No Mercy buried them; they are not coming back
|
||||
|
||||
// One card, and it never said so. This button is the only thing on the felt
|
||||
// that gives it away — a bot that forgets to call is silent by definition,
|
||||
// and the tell was always meant to be the count beside its fan. The button
|
||||
// is the table doing you the courtesy of putting a target on it.
|
||||
if (catchable[i]) {
|
||||
var got = document.createElement("button");
|
||||
got.type = "button";
|
||||
got.className = "pete-uno-catch";
|
||||
got.dataset.catch = String(i);
|
||||
got.textContent = "Catch!";
|
||||
got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
|
||||
got.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
catchSeat(i);
|
||||
});
|
||||
el.appendChild(got);
|
||||
}
|
||||
|
||||
var fan = document.createElement("div");
|
||||
fan.className = "pete-uno-fan";
|
||||
var show = Math.min(s.cards, FAN);
|
||||
@@ -252,23 +284,65 @@
|
||||
return el ? el.querySelector(".pete-uno-fan") : deckEl;
|
||||
}
|
||||
|
||||
function renderHand(v) {
|
||||
handEl.innerHTML = "";
|
||||
if (!v || !v.hand) return;
|
||||
var playable = {};
|
||||
(v.playable || []).forEach(function (i) { playable[i] = true; });
|
||||
var yours = v.turn === 0 && v.phase !== "done";
|
||||
// shownHand is the faces currently on screen. paintHand diffs against it so that
|
||||
// only cards that are actually new fan in — without it, every redraw re-deals
|
||||
// the whole hand in front of you, and a hand gets redrawn several times a lap.
|
||||
var shownHand = [];
|
||||
|
||||
v.hand.forEach(function (c, i) {
|
||||
function key(c) { return c.color + "/" + c.value; }
|
||||
|
||||
// paintHand draws your fan. `live` is whether the cards are yours to click,
|
||||
// which mid-script they are not: the game on screen is one the server has
|
||||
// already moved past, and a card clicked during the lap would be a move against
|
||||
// a board that no longer exists.
|
||||
function paintHand(cards, playable, live) {
|
||||
cards = cards || [];
|
||||
// What was already on the table stays put. Matched by face and consumed, so a
|
||||
// hand holding two red fives doesn't count one of them twice.
|
||||
var pool = shownHand.map(key);
|
||||
handEl.innerHTML = "";
|
||||
var fresh = 0;
|
||||
|
||||
cards.forEach(function (c, i) {
|
||||
var el = card(c, { button: true });
|
||||
el.style.setProperty("--i", i);
|
||||
el.dataset.at = String(i);
|
||||
var ok = yours && playable[i];
|
||||
var ok = !!live && !!playable[i];
|
||||
el.dataset.on = ok ? "1" : "0";
|
||||
el.disabled = !ok || busy;
|
||||
if (ok) el.addEventListener("click", function () { pick(i, c); });
|
||||
var was = pool.indexOf(key(c));
|
||||
if (was >= 0) {
|
||||
pool.splice(was, 1);
|
||||
el.dataset.settled = "1"; // it was already in your hand: don't deal it again
|
||||
} else {
|
||||
el.style.setProperty("--i", fresh++);
|
||||
}
|
||||
handEl.appendChild(el);
|
||||
});
|
||||
shownHand = cards.slice();
|
||||
}
|
||||
|
||||
function renderHand(v) {
|
||||
if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
|
||||
var playable = {};
|
||||
(v.playable || []).forEach(function (i) { playable[i] = true; });
|
||||
paintHand(v.hand, playable, v.turn === 0 && v.phase !== "done");
|
||||
}
|
||||
|
||||
// showHand redraws your fan from the hand the server stamped on the event that
|
||||
// just changed it — see Event.Hand in the engine.
|
||||
//
|
||||
// This is the fix for a hand that used to sit there stale for the whole lap.
|
||||
// bump() keeps the *bots'* fans honest and has always refused seat zero, so
|
||||
// when a +4 landed on you at the top of a lap you watched four backs fly into
|
||||
// your hand and then nothing: the cards themselves only turned up seconds
|
||||
// later, when the script ended and paint() finally ran. You were looking at a
|
||||
// hand you no longer held.
|
||||
function showHand(cards) {
|
||||
if (!cards) return;
|
||||
paintHand(cards, {}, false);
|
||||
// And the line above the fan counts what's in it, so it moves too.
|
||||
countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
|
||||
}
|
||||
|
||||
function renderPiles(v) {
|
||||
@@ -357,7 +431,10 @@
|
||||
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
||||
verdictEl.textContent = text;
|
||||
verdictEl.classList.remove("hidden");
|
||||
// burst() is the room's win sound as well as its confetti; anything else is
|
||||
// a night that didn't go your way.
|
||||
if (v.outcome === "won") FX.burst(verdictEl, { count: 34 });
|
||||
else FX.sfx("lose");
|
||||
}
|
||||
|
||||
// controls is the one place that decides what you can click: whose turn the
|
||||
@@ -373,6 +450,11 @@
|
||||
handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
|
||||
b.disabled = busy || !yours || b.dataset.on !== "1";
|
||||
});
|
||||
// Catching is free, but it is still a move: it can't go up while one of yours
|
||||
// is in flight, and it can't go up on somebody else's turn.
|
||||
seatsEl.querySelectorAll("[data-catch]").forEach(function (b) {
|
||||
b.disabled = busy || !yours;
|
||||
});
|
||||
// Under a stack you cannot draw. The deck is not a way out of a bill: the two
|
||||
// moves that exist are answering it with a draw card or taking the lot.
|
||||
if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
|
||||
@@ -398,6 +480,7 @@
|
||||
betting.classList.toggle("hidden", live);
|
||||
playing.classList.toggle("hidden", !live);
|
||||
hideWild();
|
||||
hideGate();
|
||||
|
||||
if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
|
||||
if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
|
||||
@@ -406,7 +489,13 @@
|
||||
// this is the table not offering a button that cannot work.
|
||||
var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
|
||||
if (passBtn) passBtn.classList.toggle("hidden", !canPass);
|
||||
if (hintEl && live) hintEl.textContent = HINTS[v.phase] || HINTS.play;
|
||||
if (hintEl && live) {
|
||||
// A seat sitting on one card it never called is the most valuable thing on
|
||||
// the table, and it is worth more than whatever the phase was going to say.
|
||||
hintEl.textContent = (v.catchable || []).length
|
||||
? "Somebody's down to one card and never said so. Catch them before you play."
|
||||
: (HINTS[v.phase] || HINTS.play);
|
||||
}
|
||||
if (!live && v) rulesFor(v); // it's over: leave the panel where the game was
|
||||
controls();
|
||||
if (!v || !v.outcome) verdictEl.classList.add("hidden");
|
||||
@@ -426,7 +515,9 @@
|
||||
|
||||
// ---- the script ------------------------------------------------------------
|
||||
|
||||
// throwCard sends a card from one place to another across the felt.
|
||||
// throwCard sends a card from one place to another across the felt. It lands
|
||||
// with a slap — "card" for one going down on the pile, "deal" (softer, lower)
|
||||
// for one coming off the deck into a hand, which is a card being *placed*.
|
||||
function throwCard(node, from, to, opts) {
|
||||
opts = opts || {};
|
||||
return FX.flyNode(node, from, to, {
|
||||
@@ -435,12 +526,17 @@
|
||||
spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
|
||||
fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
|
||||
delay: opts.delay || 0,
|
||||
index: opts.index || 0,
|
||||
sound: opts.sound === undefined ? "card" : opts.sound,
|
||||
});
|
||||
}
|
||||
|
||||
// bump keeps a bot's fan honest during the script: the server's count is the
|
||||
// truth, but between events the fan has to grow and shrink as cards move, or
|
||||
// the flight lands on a pile that hasn't changed.
|
||||
//
|
||||
// Seat zero is not a bot and is not bumped — a fan of backs is no use to you,
|
||||
// you want the faces. showHand does yours, from the hand the event carries.
|
||||
function bump(seat, left) {
|
||||
if (seat === 0 || left == null) return;
|
||||
var el = seatEl(seat);
|
||||
@@ -499,6 +595,10 @@
|
||||
// The deal isn't animated card by card: seven cards to each of four
|
||||
// seats is 28 flights and a player who wants to play. The hand fans in
|
||||
// on its own (CSS), which reads as being dealt without taking as long.
|
||||
// The sound does the same job: a riffle, then cards landing, rather
|
||||
// than twenty-eight separate slaps.
|
||||
FX.sfx("shuffle");
|
||||
for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
|
||||
paint(final);
|
||||
return wait(320);
|
||||
|
||||
@@ -525,6 +625,7 @@
|
||||
colourEl.dataset.c = e.color;
|
||||
feltEl.dataset.c = e.color;
|
||||
}
|
||||
showHand(e.hand); // the card has landed; close the gap it left
|
||||
return wait(e.seat === 0 ? 120 : 300);
|
||||
});
|
||||
}
|
||||
@@ -535,14 +636,16 @@
|
||||
var to = seatAnchor(e.seat);
|
||||
var backs = [];
|
||||
for (var i = 0; i < Math.min(e.n, 4); i++) {
|
||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90 }));
|
||||
backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" }));
|
||||
}
|
||||
var punished = e.kind === "forced";
|
||||
if (punished) FX.sfx("bad");
|
||||
// A forced draw is also how a stack ends: somebody stopped answering
|
||||
// and paid the bill, so the bill is gone.
|
||||
if (punished) { pending(0); badge(e.seat, "+" + e.n, "bad"); }
|
||||
return Promise.all(backs).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
showHand(e.hand); // your cards, face up, as they land
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
// Your own drawn card comes face up — it's yours, and the server
|
||||
// sent its face for exactly this.
|
||||
@@ -551,12 +654,58 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the call, and the catch ---------------------------------------
|
||||
//
|
||||
// Somebody went down to one card without saying so, and somebody else
|
||||
// noticed. Two cards off the deck, and the seat that took them wears the
|
||||
// name of whoever spotted it — because "caught" with nobody attached is
|
||||
// just a mystery.
|
||||
case "caught": {
|
||||
spotlight(e.seat);
|
||||
var mine = e.seat === 0;
|
||||
var caughtBy = (game && game.seats && game.seats[e.by]) || {};
|
||||
var whom = mine
|
||||
? "Caught! " + (caughtBy.name || "They") + " saw it"
|
||||
: "Caught! +" + e.n;
|
||||
// Catching a bot is a pointed finger; being caught is a thud.
|
||||
FX.sfx(mine ? "bad" : "catch");
|
||||
var lands = [];
|
||||
var at = seatAnchor(e.seat);
|
||||
for (var k = 0; k < e.n; k++) {
|
||||
lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
|
||||
}
|
||||
badge(e.seat, whom, "bad");
|
||||
return Promise.all(lands).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
showHand(e.hand);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
return wait(520);
|
||||
});
|
||||
}
|
||||
|
||||
// You called a seat that had nothing to hide. Two cards, and they're
|
||||
// yours: the price of the catch button not being a thing you just mash.
|
||||
case "miscall": {
|
||||
FX.sfx("bad");
|
||||
var wrong = [];
|
||||
for (var w = 0; w < e.n; w++) {
|
||||
wrong.push(throwCard(back(), deckEl, handEl, { index: w, delay: w * 100, sound: "deal" }));
|
||||
}
|
||||
badge(0, "They called it. +" + e.n, "bad");
|
||||
return Promise.all(wrong).then(function () {
|
||||
showHand(e.hand);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
return wait(520);
|
||||
});
|
||||
}
|
||||
|
||||
case "stack":
|
||||
// The bill has moved on to somebody, and N is what it stands at now —
|
||||
// not what was just added to it. A +2 answered with a +10 is a seat
|
||||
// looking at twelve cards.
|
||||
pending(e.n);
|
||||
spotlight(e.seat);
|
||||
FX.sfx("bad");
|
||||
return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });
|
||||
|
||||
case "skipall":
|
||||
@@ -580,7 +729,10 @@
|
||||
dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
|
||||
}
|
||||
badge(e.seat, "−" + e.n + " " + (e.color || ""));
|
||||
return Promise.all(dumped).then(function () { return wait(220); });
|
||||
return Promise.all(dumped).then(function () {
|
||||
showHand(e.hand);
|
||||
return wait(220);
|
||||
});
|
||||
}
|
||||
|
||||
case "roulette": {
|
||||
@@ -595,6 +747,7 @@
|
||||
badge(e.seat, "Roulette +" + e.n, "bad");
|
||||
return Promise.all(spun).then(function () {
|
||||
bump(e.seat, e.left);
|
||||
showHand(e.hand);
|
||||
deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
|
||||
return wait(400);
|
||||
});
|
||||
@@ -606,7 +759,9 @@
|
||||
var grave = seatEl(e.seat);
|
||||
if (grave) grave.dataset.out = "1";
|
||||
bump(e.seat, 0);
|
||||
showHand(e.hand); // if it was you, the hand goes with you
|
||||
pending(0); // a dead seat pays no bill and leaves none behind
|
||||
FX.sfx("bad");
|
||||
return badge(e.seat, "Buried on " + e.n, "bad").then(function () { return wait(460); });
|
||||
}
|
||||
|
||||
@@ -618,6 +773,7 @@
|
||||
return badge(e.seat, "Reverse").then(function () { return wait(60); });
|
||||
|
||||
case "uno":
|
||||
FX.sfx("uno");
|
||||
return badge(e.seat, "UNO!", "uno");
|
||||
|
||||
case "reshuffle":
|
||||
@@ -626,6 +782,7 @@
|
||||
// and counting down from a deck that still reads empty gets nowhere.
|
||||
deckCntEl.textContent = String(e.n);
|
||||
deckEl.classList.add("pete-uno-shuffle");
|
||||
FX.sfx("shuffle");
|
||||
return wait(420).then(function () {
|
||||
deckEl.classList.remove("pete-uno-shuffle");
|
||||
});
|
||||
@@ -699,8 +856,72 @@
|
||||
|
||||
function pick(i, c) {
|
||||
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
|
||||
if (c.wild) { askColour(i); return; }
|
||||
move({ kind: "play", index: i });
|
||||
if (c.wild) { askColour(i); return; } // the colour first; the call comes after
|
||||
offer(i, 0);
|
||||
}
|
||||
|
||||
// offer is the last thing between a card and the pile. If playing it leaves you
|
||||
// holding one card, the table wants a word out of you first.
|
||||
//
|
||||
// Which cards those are is the server's answer, not a count of your hand: No
|
||||
// Mercy's "discard all" takes every card of its colour with it, so a six-card
|
||||
// hand can land on one, and a browser subtracting one from six would let you
|
||||
// walk into a catch it never warned you about.
|
||||
function offer(i, color) {
|
||||
if (game && (game.uno_at || []).indexOf(i) >= 0) {
|
||||
openGate(i, color);
|
||||
return;
|
||||
}
|
||||
move({ kind: "play", index: i, color: color });
|
||||
}
|
||||
|
||||
function openGate(i, color) {
|
||||
gate = { index: i, color: color };
|
||||
gateEl.classList.remove("hidden");
|
||||
if (timerEl) {
|
||||
// Restart the drain. The bar is a CSS animation and this element gets
|
||||
// reused, so without kicking the browser between the two assignments it
|
||||
// stays where the last one finished — a full bar that never moves, which
|
||||
// is worse than no bar at all.
|
||||
timerEl.style.animation = "none";
|
||||
void timerEl.offsetWidth;
|
||||
timerEl.style.animation = "";
|
||||
timerEl.style.animationDuration = CALL_MS + "ms";
|
||||
}
|
||||
if (callBtn) callBtn.focus();
|
||||
gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
|
||||
}
|
||||
|
||||
// shut plays the card the gate was holding, with or without the word. Letting
|
||||
// the clock run out is a real answer — it is the answer that gets you caught.
|
||||
function shut(called) {
|
||||
if (!gate) return;
|
||||
var g = gate;
|
||||
gate = null;
|
||||
clearTimeout(gateTimer);
|
||||
gateTimer = 0;
|
||||
gateEl.classList.add("hidden");
|
||||
move({ kind: "play", index: g.index, color: g.color, uno: called });
|
||||
}
|
||||
|
||||
// hideGate drops the gate without playing anything. Only for the paths that tear
|
||||
// the board down under it (an error, a resume) — a gate that outlived its game
|
||||
// would play a card into the next one.
|
||||
function hideGate() {
|
||||
if (gateTimer) clearTimeout(gateTimer);
|
||||
gateTimer = 0;
|
||||
gate = null;
|
||||
if (gateEl) gateEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (callBtn) callBtn.addEventListener("click", function () { shut(true); });
|
||||
|
||||
// Catching a seat that went quiet. It is not a turn — right or wrong, the turn
|
||||
// stays with you — but it is a move, so it goes through the same door as one.
|
||||
function catchSeat(seat) {
|
||||
if (busy || !game || game.phase === "done" || game.turn !== 0) return;
|
||||
if ((game.catchable || []).indexOf(seat) < 0) return;
|
||||
move({ kind: "catch", seat: seat });
|
||||
}
|
||||
|
||||
function askColour(i) {
|
||||
@@ -724,7 +945,7 @@
|
||||
var i = pendingWild;
|
||||
var c = COLOURS[b.dataset.colourPick];
|
||||
hideWild();
|
||||
move({ kind: "play", index: i, color: c });
|
||||
offer(i, c); // named the colour; now, is it your last but one?
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user