Three front-end changes to the casino games:
- uno: a draw-card stack now names the seat it's actually pointed at
("+N on Beep") instead of always reading "+N on you". The bill reads
v.turn, which the engine advances to the target when a draw card lands.
- uno: the No Mercy rule buries a seat with some ceremony now. bury()
rolls one of four send-offs over the seat (or your hand, if it's you) —
a rockslide that piles up, a tombstone, a coffin, or the Mega Man death
burst — each with its own synth sound, and a static headstone under
reduced motion. The "Buried on N" badge still rides alongside.
- all games: winning makes it rain. FX.moneyRain drops a curtain of bills
and coins with a jackpot coin-cascade sound, gated by significance so it
reads as a win and not as noise: hold'em keeps its light per-pot confetti
and only rains on a real haul (>= 20bb), while the confetti stays the
rare cherry (natural 21, full board clear, phrase solved outright).
764 lines
29 KiB
JavaScript
764 lines
29 KiB
JavaScript
// The solitaire table.
|
|
//
|
|
// Blackjack plays back a *script*: the server sends one event per card off the
|
|
// shoe and the table deals them out in order. That works because a blackjack hand
|
|
// only ever grows at one end. Solitaire doesn't: a move takes a run from anywhere
|
|
// and puts it anywhere, an auto-finish moves eleven cards at once, and a single
|
|
// move can turn a card over three columns away. A script of "append this card
|
|
// there" would be a second engine over here, and it would be the one that's wrong.
|
|
//
|
|
// So this table re-renders the whole board from the server's view after every
|
|
// move, and then animates the difference — FLIP: measure where every card *was*,
|
|
// re-render, measure where it *is*, and play each card from its old place to its
|
|
// new one. The board on screen is therefore always exactly the board the server
|
|
// says exists, and the animation is derived from it rather than the other way
|
|
// round. The events are still used, for the two things a diff genuinely cannot
|
|
// tell you: where a newly-revealed card came from (out of the stock, or turned
|
|
// over in place), and what the board is now worth.
|
|
//
|
|
// The money follows the same rule as every other table in the room: nothing about
|
|
// it changes without a chip crossing the felt to make it change. Here the stake
|
|
// buys the deck — it goes to the house and does not come back — and the spot in
|
|
// the rail holds what you've *banked*, which grows by one card's worth every time
|
|
// a card reaches a foundation and shrinks again if you take one back off.
|
|
(function () {
|
|
"use strict";
|
|
|
|
var root = document.querySelector("[data-solitaire]");
|
|
if (!root) return;
|
|
|
|
var FX = window.PeteFX;
|
|
var CARDS = window.PeteCards;
|
|
|
|
var stockEl = root.querySelector("[data-stock]");
|
|
var stockCountEl = root.querySelector("[data-stock-count]");
|
|
var stockRecycleEl = root.querySelector("[data-stock-recycle]");
|
|
var wasteEl = root.querySelector("[data-waste]");
|
|
var foundEl = root.querySelector("[data-foundations]");
|
|
var tableauEl = root.querySelector("[data-tableau]");
|
|
var verdictEl = root.querySelector("[data-verdict]");
|
|
var homeEl = root.querySelector("[data-home]");
|
|
var perCardEl = root.querySelector("[data-per-card]");
|
|
var breakEvenEl = root.querySelector("[data-break-even]");
|
|
var meterEl = root.querySelector("[data-meter]");
|
|
|
|
var playing = root.querySelector("[data-playing]");
|
|
var betting = root.querySelector("[data-betting]");
|
|
var autoBtn = root.querySelector("[data-auto]");
|
|
var cashBtn = root.querySelector("[data-cash]");
|
|
var cashAmountEl = root.querySelector("[data-cash-amount]");
|
|
var startBtn = root.querySelector("[data-start]");
|
|
var betAmountEl = root.querySelector("[data-bet-amount]");
|
|
var gameMsg = root.querySelector("[data-game-msg]");
|
|
var tableMsg = root.querySelector("[data-table-msg]");
|
|
|
|
var purseEl = document.querySelector("[data-chips]");
|
|
var spotEl = root.querySelector("[data-spot]");
|
|
var houseEl = root.querySelector("[data-house]");
|
|
|
|
// The spot in the rail. On this table it holds what you've banked, not a bet.
|
|
var spot = FX.spot({
|
|
spot: spotEl,
|
|
stack: root.querySelector("[data-stack]"),
|
|
total: root.querySelector("[data-spot-total]"),
|
|
});
|
|
|
|
var FULL = 52;
|
|
var MOVE_MS = 300; // one card's journey across the board
|
|
var STEP_MS = 95; // the gap between two cards in a cascade
|
|
var reduced = FX.reduced;
|
|
|
|
var bet = 0; // the deck you're building the price of
|
|
var tier = null; // which deal
|
|
var busy = false; // a request is in flight, or cards are still moving
|
|
var board = null; // the board as the server last described it
|
|
var held = null; // {pile, count, cards} — the run in your hand
|
|
|
|
function wait(ms) {
|
|
return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); });
|
|
}
|
|
|
|
function say(el, text, tone) {
|
|
if (!text) { el.classList.add("hidden"); return; }
|
|
el.textContent = text;
|
|
el.classList.remove("hidden");
|
|
el.style.color = tone === "bad" ? "#cc3d4a" : "";
|
|
}
|
|
|
|
// ---- the rules, as the *browser* understands them --------------------------
|
|
//
|
|
// These mirror the engine, and they are not the engine: the server decides every
|
|
// move and will refuse one this file thought was fine. They exist because you
|
|
// cannot light up the columns a card can go to without knowing which those are,
|
|
// and a table that makes you find that out by being told no is a table that
|
|
// teaches Klondike by refusal. When the two disagree the server wins and the
|
|
// board snaps back to whatever it says — see send().
|
|
|
|
var RANKS = { A: 1, J: 11, Q: 12, K: 13 };
|
|
function rank(c) { return RANKS[c.rank] || parseInt(c.rank, 10); }
|
|
|
|
// isRun: descending by one, alternating colour — the only thing you may lift
|
|
// off a column as a block.
|
|
function isRun(cs) {
|
|
for (var i = 1; i < cs.length; i++) {
|
|
if (rank(cs[i]) !== rank(cs[i - 1]) - 1 || cs[i].red === cs[i - 1].red) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// accepts: what may be put where. A foundation takes its own suit in order from
|
|
// the ace; a column takes a run that descends and alternates from its top card,
|
|
// and an empty column takes a King and nothing else.
|
|
function accepts(pile, cs) {
|
|
if (!board || !cs || !cs.length) return false;
|
|
|
|
if (pile.charAt(0) === "f") {
|
|
var f = board.found[parseInt(pile.slice(1), 10)];
|
|
return !!f && cs.length === 1 && cs[0].suit === f.suit && rank(cs[0]) === f.n + 1;
|
|
}
|
|
|
|
var col = board.table[parseInt(pile.slice(1), 10)];
|
|
if (!col || !isRun(cs)) return false;
|
|
if (!col.up || !col.up.length) return col.down === 0 && rank(cs[0]) === 13;
|
|
var top = col.up[col.up.length - 1];
|
|
return rank(cs[0]) === rank(top) - 1 && cs[0].red !== top.red;
|
|
}
|
|
|
|
// cardsAt is the run you'd be picking up by clicking this card.
|
|
function cardsAt(pile, idx) {
|
|
if (!board) return null;
|
|
if (pile === "waste") {
|
|
return board.waste && board.waste.length ? [board.waste[board.waste.length - 1]] : null;
|
|
}
|
|
if (pile.charAt(0) === "f") {
|
|
var f = board.found[parseInt(pile.slice(1), 10)];
|
|
return f && f.top ? [f.top] : null;
|
|
}
|
|
var col = board.table[parseInt(pile.slice(1), 10)];
|
|
if (!col || !col.up || idx >= col.up.length) return null;
|
|
return col.up.slice(idx);
|
|
}
|
|
|
|
// ---- drawing the board -----------------------------------------------------
|
|
|
|
// face builds a card element wired up for clicking. No deal flight and no tilt:
|
|
// this table animates its own cards from wherever they actually came from, and a
|
|
// column of thirteen tilted cards overlapping by an eighth of an inch reads as a
|
|
// mistake rather than as a hand that was handled.
|
|
function face(c, pile, idx) {
|
|
var el = CARDS.el(c, { deal: false, tilt: false });
|
|
el.dataset.pile = pile;
|
|
el.dataset.idx = String(idx);
|
|
return el;
|
|
}
|
|
|
|
function slot(pile, glyph, red) {
|
|
var el = document.createElement("div");
|
|
el.className = "pete-slot";
|
|
el.dataset.pile = pile;
|
|
if (glyph) {
|
|
el.innerHTML = '<span class="pete-slot-glyph" data-red="' + (red ? "1" : "0") + '">' + glyph + "</span>";
|
|
}
|
|
return el;
|
|
}
|
|
|
|
function render(v) {
|
|
board = v;
|
|
if (!v) {
|
|
wasteEl.innerHTML = "";
|
|
foundEl.innerHTML = "";
|
|
tableauEl.innerHTML = "";
|
|
stockEl.dataset.dead = "1";
|
|
stockCountEl.classList.add("hidden");
|
|
stockRecycleEl.classList.add("hidden");
|
|
meter(null);
|
|
return;
|
|
}
|
|
|
|
// The stock. Empty with a pass left is not the same thing as empty with none:
|
|
// one is a gesture you can still make and the other is a dead pile, and the
|
|
// difference is worth showing rather than leaving to a click that gets refused.
|
|
var canRecycle = v.stock === 0 && v.waste_n > 0 && (v.passes < 0 || v.passes > 1);
|
|
stockEl.dataset.empty = v.stock === 0 ? "1" : "0";
|
|
stockEl.dataset.dead = v.stock === 0 && !canRecycle ? "1" : "0";
|
|
stockCountEl.textContent = String(v.stock);
|
|
stockCountEl.classList.toggle("hidden", v.stock === 0);
|
|
stockRecycleEl.classList.toggle("hidden", !canRecycle);
|
|
|
|
// The waste: the last three, fanned, and only the top one is yours to take.
|
|
wasteEl.innerHTML = "";
|
|
(v.waste || []).forEach(function (c, i, all) {
|
|
var el = face(c, "waste", i);
|
|
if (i === all.length - 1) el.dataset.live = "1";
|
|
wasteEl.appendChild(el);
|
|
});
|
|
|
|
// The foundations. Each is a slot that stays put whether or not there's a card
|
|
// on it, so the board doesn't reflow the moment an ace goes home — and so a
|
|
// drop target has somewhere to be even when it's empty.
|
|
foundEl.innerHTML = "";
|
|
v.found.forEach(function (f, i) {
|
|
var s = slot("f" + i, f.suit, f.red);
|
|
if (f.top) {
|
|
var el = face(f.top, "f" + i, 0);
|
|
el.dataset.live = "1";
|
|
s.appendChild(el);
|
|
}
|
|
foundEl.appendChild(s);
|
|
});
|
|
|
|
// The seven columns.
|
|
tableauEl.innerHTML = "";
|
|
v.table.forEach(function (col, i) {
|
|
var c = document.createElement("div");
|
|
c.className = "pete-col";
|
|
c.dataset.pile = "t" + i;
|
|
|
|
if (!col.down && !(col.up && col.up.length)) {
|
|
c.appendChild(slot("t" + i));
|
|
}
|
|
for (var d = 0; d < col.down; d++) {
|
|
c.appendChild(CARDS.el(null, { deal: false, tilt: false }));
|
|
}
|
|
(col.up || []).forEach(function (card, j) {
|
|
var el = face(card, "t" + i, j);
|
|
// A card is pickable if the run from it down is a run. Anything else is a
|
|
// card you can see and can't lift, and it shouldn't offer.
|
|
if (isRun(col.up.slice(j))) el.dataset.live = "1";
|
|
c.appendChild(el);
|
|
});
|
|
tableauEl.appendChild(c);
|
|
});
|
|
|
|
meter(v);
|
|
controls(v);
|
|
if (held) mark(); // a selection survives a re-render, so redraw what it lit
|
|
}
|
|
|
|
// meter is what the board is worth. Every number in it comes off the server —
|
|
// this file does no arithmetic about money, which is the point.
|
|
function meter(v) {
|
|
if (!v) {
|
|
homeEl.innerHTML = '0<span class="text-white/40">/' + FULL + "</span>";
|
|
perCardEl.textContent = "—";
|
|
breakEvenEl.textContent = "";
|
|
return;
|
|
}
|
|
homeEl.innerHTML = v.home + '<span class="text-white/40">/' + FULL + "</span>";
|
|
perCardEl.textContent = "+" + v.per_card.toFixed(1);
|
|
breakEvenEl.textContent =
|
|
v.home >= v.break_even
|
|
? "You're ahead of the house"
|
|
: v.break_even - v.home + " more to break even";
|
|
meterEl.dataset.cold = v.home === 0 ? "1" : "0";
|
|
}
|
|
|
|
function controls(v) {
|
|
var live = !!v && v.phase === "playing";
|
|
playing.classList.toggle("hidden", !live);
|
|
betting.classList.toggle("hidden", live);
|
|
if (!live) return;
|
|
autoBtn.disabled = !v.can_auto;
|
|
cashAmountEl.textContent = (v.stands || 0).toLocaleString();
|
|
}
|
|
|
|
// ---- FLIP ------------------------------------------------------------------
|
|
//
|
|
// Where every card is, right now. One deck, so a card's label is its identity.
|
|
|
|
function snapshot() {
|
|
var map = {};
|
|
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
|
map[el.dataset.key] = el.getBoundingClientRect();
|
|
});
|
|
map["#stock"] = stockEl.getBoundingClientRect();
|
|
return map;
|
|
}
|
|
|
|
// plan reads the events for the two things a before/after diff can't tell you:
|
|
// where a card that is *new to the board* came from, and how much of a beat to
|
|
// leave before it moves, so that an auto-finish cascades rather than teleporting.
|
|
function planOf(events) {
|
|
var origins = {}, delays = {}, at = 0;
|
|
(events || []).forEach(function (e) {
|
|
(e.cards || []).forEach(function (c) {
|
|
if (e.kind === "draw") origins[c.label] = "draw";
|
|
if (e.kind === "flip") origins[c.label] = "flip";
|
|
delays[c.label] = at;
|
|
});
|
|
if (e.kind === "move" || e.kind === "home") at += STEP_MS;
|
|
});
|
|
return { origins: origins, delays: delays };
|
|
}
|
|
|
|
// animate plays every card from where it was to where it is.
|
|
function animate(before, plan) {
|
|
if (reduced) return Promise.resolve();
|
|
var waits = [];
|
|
|
|
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
|
|
var key = el.dataset.key;
|
|
var now = el.getBoundingClientRect();
|
|
var was = before[key];
|
|
var delay = plan.delays[key] || 0;
|
|
var origin = plan.origins[key];
|
|
|
|
// A card that was already on the board: play it from its old place. This is
|
|
// every ordinary move, and it is also what makes an eleven-card auto-finish
|
|
// animate itself for free.
|
|
if (was && !origin) {
|
|
var dx = was.left - now.left;
|
|
var dy = was.top - now.top;
|
|
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
|
|
waits.push(slide(el, dx, dy, delay));
|
|
return;
|
|
}
|
|
|
|
// A card that has just been turned over. Out of the stock it flies as well as
|
|
// turns; in a column it turns where it lies.
|
|
if (origin === "draw") {
|
|
var from = before["#stock"];
|
|
waits.push(slide(el, from.left - now.left, from.top - now.top, delay));
|
|
waits.push(turn(el, delay));
|
|
} else if (origin === "flip") {
|
|
waits.push(turn(el, delay));
|
|
}
|
|
});
|
|
|
|
return Promise.all(waits);
|
|
}
|
|
|
|
function slide(el, dx, dy, delay) {
|
|
return el.animate(
|
|
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
|
|
{
|
|
duration: MOVE_MS,
|
|
delay: delay,
|
|
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
|
fill: "backwards",
|
|
}
|
|
).finished.catch(noop);
|
|
}
|
|
|
|
// The card turns over on its own axis. The wrapper is doing the travelling, so
|
|
// this has to be the inner face or the two transforms would fight.
|
|
function turn(el, delay) {
|
|
var inner = el.querySelector(".pete-card-inner");
|
|
return inner.animate(
|
|
[{ transform: "rotateY(180deg)" }, { transform: "rotateY(0deg)" }],
|
|
{ duration: MOVE_MS, delay: delay, easing: "cubic-bezier(0.4, 0, 0.2, 1)", fill: "backwards" }
|
|
).finished.catch(noop);
|
|
}
|
|
|
|
// The recycle is the one move where cards *leave* the board, so FLIP has nothing
|
|
// to animate: they're gone from the new render before it can measure them. They
|
|
// get their flight here instead, out of the old DOM, before it's replaced.
|
|
function recycleOut() {
|
|
if (reduced) return Promise.resolve();
|
|
var to = stockEl.getBoundingClientRect();
|
|
var waits = [];
|
|
wasteEl.querySelectorAll(".pete-card").forEach(function (el, i) {
|
|
var now = el.getBoundingClientRect();
|
|
waits.push(
|
|
el.animate(
|
|
[
|
|
{ transform: "none", opacity: 1 },
|
|
{ transform: "translate(" + (to.left - now.left) + "px," + (to.top - now.top) + "px) rotateY(180deg)", opacity: 1 },
|
|
],
|
|
{ duration: 260, delay: i * 50, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" }
|
|
).finished.catch(noop)
|
|
);
|
|
});
|
|
return Promise.all(waits);
|
|
}
|
|
|
|
function noop() {}
|
|
|
|
// A card reaching a foundation is the only move in this game that pays you, so
|
|
// it's the only one that makes a noise about it.
|
|
function flashHome(events) {
|
|
if (reduced) return;
|
|
var at = 0;
|
|
(events || []).forEach(function (e) {
|
|
if (e.kind !== "home" || !e.to) {
|
|
if (e.kind === "move") at += STEP_MS;
|
|
return;
|
|
}
|
|
var when = at + MOVE_MS;
|
|
at += STEP_MS;
|
|
setTimeout(function () {
|
|
var pile = foundEl.querySelector('[data-pile="' + e.to + '"]');
|
|
if (!pile) return;
|
|
pile.classList.remove("pete-home-flash");
|
|
void pile.offsetWidth; // restart the animation if it's still running
|
|
pile.classList.add("pete-home-flash");
|
|
}, when);
|
|
});
|
|
}
|
|
|
|
// 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
|
|
// out of the house's rack; down — a card taken back off a foundation — is chips
|
|
// going back to it. Either way the pile moves before the number does.
|
|
function bank(pays) {
|
|
var delta = (pays || 0) - spot.amount;
|
|
if (delta === 0) return Promise.resolve();
|
|
if (delta > 0) return spot.pour(houseEl, delta, { gap: 55 });
|
|
return spot.sweep(houseEl, -delta, { gap: 40, lift: 0.6, fade: true });
|
|
}
|
|
|
|
// ---- picking cards up ------------------------------------------------------
|
|
|
|
function mark() {
|
|
root.querySelectorAll('[data-held="1"]').forEach(function (el) { delete el.dataset.held; });
|
|
root.querySelectorAll('[data-drop="1"]').forEach(function (el) { delete el.dataset.drop; });
|
|
if (!held) return;
|
|
|
|
// The run in your hand lifts off the felt.
|
|
root.querySelectorAll('.pete-card[data-pile="' + held.pile + '"]').forEach(function (el) {
|
|
if (held.pile === "waste" || held.pile.charAt(0) === "f") {
|
|
if (el.dataset.live === "1") el.dataset.held = "1";
|
|
} else if (parseInt(el.dataset.idx, 10) >= held.idx) {
|
|
el.dataset.held = "1";
|
|
}
|
|
});
|
|
|
|
// And everywhere it could go lights up. This is the whole reason the rules are
|
|
// mirrored over here: being shown where a card goes is the game teaching you,
|
|
// and being told no after you commit is the game scolding you.
|
|
root.querySelectorAll("[data-pile]").forEach(function (el) {
|
|
var pile = el.dataset.pile;
|
|
if (pile === held.pile || pile === "waste" || el.classList.contains("pete-card")) return;
|
|
if (accepts(pile, held.cards)) el.dataset.drop = "1";
|
|
});
|
|
}
|
|
|
|
function pick(pile, idx) {
|
|
var cs = cardsAt(pile, idx);
|
|
if (!cs || !isRun(cs)) return;
|
|
held = { pile: pile, idx: idx, count: cs.length, cards: cs };
|
|
mark();
|
|
}
|
|
|
|
function drop() {
|
|
held = null;
|
|
mark();
|
|
}
|
|
|
|
function nope(el) {
|
|
if (!el || reduced) return;
|
|
el.classList.remove("pete-nope");
|
|
void el.offsetWidth;
|
|
el.classList.add("pete-nope");
|
|
}
|
|
|
|
// A click on the board. The order matters: if something is in your hand and the
|
|
// thing you clicked will take it, that's a move — otherwise it's you picking up
|
|
// something else. Which means you never have to put a card down before choosing
|
|
// a different one.
|
|
root.querySelector(".pete-felt").addEventListener("click", function (e) {
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
if (e.target.closest("[data-stock]")) return; // the stock has its own handler
|
|
|
|
var pileEl = e.target.closest("[data-pile]");
|
|
if (!pileEl) { drop(); return; }
|
|
|
|
var cardEl = e.target.closest(".pete-card[data-key]");
|
|
var pile = pileEl.dataset.pile;
|
|
|
|
if (held) {
|
|
if (pile === held.pile) { drop(); return; } // clicking your own run puts it down
|
|
if (accepts(pile, held.cards)) {
|
|
var move = { kind: "move", from: held.pile, to: pile, count: held.count };
|
|
drop();
|
|
send(move);
|
|
return;
|
|
}
|
|
// Not a place it goes. If what you clicked is a card you *could* pick up,
|
|
// this was a change of mind rather than a bad move; only shake at a genuine
|
|
// dead end.
|
|
if (!cardEl || cardEl.dataset.live !== "1") { nope(pileEl); return; }
|
|
}
|
|
|
|
if (cardEl && cardEl.dataset.live === "1") {
|
|
pick(pile, parseInt(cardEl.dataset.idx, 10));
|
|
} else {
|
|
drop();
|
|
}
|
|
});
|
|
|
|
// Double-click sends a card home. It's the idiom every solitaire has used for
|
|
// thirty years, and the alternative is asking the player which foundation — a
|
|
// question with exactly one right answer.
|
|
root.addEventListener("dblclick", function (e) {
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
var cardEl = e.target.closest('.pete-card[data-live="1"]');
|
|
if (!cardEl) return;
|
|
var pile = cardEl.dataset.pile;
|
|
if (pile.charAt(0) === "f") return; // it's already home
|
|
e.preventDefault();
|
|
drop();
|
|
send({ kind: "home", from: pile });
|
|
});
|
|
|
|
stockEl.addEventListener("click", function () {
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
if (stockEl.dataset.dead === "1") {
|
|
say(gameMsg, "That was your last pass through the stock.", "bad");
|
|
nope(stockEl);
|
|
return;
|
|
}
|
|
drop();
|
|
send({ kind: "draw" });
|
|
});
|
|
|
|
autoBtn.addEventListener("click", function () { drop(); send({ kind: "auto" }); });
|
|
|
|
cashBtn.addEventListener("click", function () {
|
|
drop();
|
|
send({ kind: "concede" });
|
|
});
|
|
|
|
document.addEventListener("keydown", function (e) {
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
|
|
if (e.key === "Escape") { drop(); return; }
|
|
if (busy || !board || board.phase !== "playing") return;
|
|
|
|
var k = e.key.toLowerCase();
|
|
if (k === " " || k === "d") { e.preventDefault(); stockEl.click(); }
|
|
else if (k === "a" && !autoBtn.disabled) { e.preventDefault(); autoBtn.click(); }
|
|
});
|
|
|
|
// ---- talking to the table --------------------------------------------------
|
|
|
|
var VERDICTS = {
|
|
cleared: "Cleared the board! 🎉",
|
|
cashed: "Board cashed.",
|
|
};
|
|
|
|
function verdict(v) {
|
|
var text = VERDICTS[v.outcome] || "";
|
|
if (!text) { verdictEl.classList.add("hidden"); return; }
|
|
if (v.net > 0) text += " +" + v.net.toLocaleString();
|
|
else if (v.net < 0) text += " " + v.net.toLocaleString();
|
|
verdictEl.textContent = text;
|
|
verdictEl.classList.remove("hidden");
|
|
// Clearing 52 cards out of a Vegas deal is the rarest thing that happens in
|
|
// this room, so it keeps the confetti — but any win now makes it rain.
|
|
if (v.outcome === "cleared") { FX.burst(verdictEl, { count: 40 }); FX.moneyRain({ count: 34 }); }
|
|
else if (v.net > 0) FX.moneyRain();
|
|
else if (v.net < 0) FX.sfx("lose");
|
|
else FX.sfx("push");
|
|
}
|
|
|
|
// play walks a server response onto the felt: the board is re-rendered, the
|
|
// cards animate from where they were, and the chips follow.
|
|
//
|
|
// `money` — the chip bar catching up — is held back on a *settling* board until
|
|
// the payout has physically swept home. A counter that pays you before the chips
|
|
// arrive is a counter that has told you the ending.
|
|
function play(view, money) {
|
|
var v = view.solitaire;
|
|
var events = view.sol_events || [];
|
|
var settles = !!v && v.phase === "done";
|
|
var recycled = events.some(function (e) { return e.kind === "recycle"; });
|
|
|
|
var pre = recycled ? recycleOut() : Promise.resolve();
|
|
|
|
return pre
|
|
.then(function () {
|
|
var before = snapshot();
|
|
render(v);
|
|
var plan = planOf(events);
|
|
flashHome(events);
|
|
noises(events);
|
|
return animate(before, plan);
|
|
})
|
|
.then(function () {
|
|
if (!v) { money(); return; }
|
|
return bank(v.stands).then(function () {
|
|
if (!settles) { money(); return; }
|
|
// The board is finished. Everything banked comes home, and only then
|
|
// does the number in the bar move.
|
|
verdict(v);
|
|
return wait(300)
|
|
.then(function () { return spot.sweep(purseEl, v.payout, { gap: 40, lift: 0.8 }); })
|
|
.then(money)
|
|
.then(function () { controls(null); showBet(); });
|
|
});
|
|
});
|
|
}
|
|
|
|
function send(move) {
|
|
if (busy) return;
|
|
busy = true;
|
|
say(gameMsg, "");
|
|
return window.PeteGames.post("/api/games/solitaire/move", move)
|
|
.then(function (view) { return play(view, function () { window.PeteGames.apply(view); }); })
|
|
.catch(function (err) {
|
|
say(gameMsg, err.message, "bad");
|
|
// Whatever this file thought the board was, the server is the authority on
|
|
// it. Ask, and draw what it says.
|
|
return window.PeteGames.refresh().then(function (v) {
|
|
if (v) { render(v.solitaire || null); spot.render(v.solitaire ? v.solitaire.stands : 0); }
|
|
});
|
|
})
|
|
.then(function () { busy = false; });
|
|
}
|
|
|
|
// ---- buying a deck ---------------------------------------------------------
|
|
|
|
function showBet() {
|
|
betAmountEl.textContent = bet.toLocaleString();
|
|
var money = window.PeteGames.view();
|
|
startBtn.disabled = bet <= 0 || !tier || !money || money.chips < bet;
|
|
}
|
|
|
|
root.querySelectorAll("[data-tier]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
tier = btn.dataset.tier;
|
|
root.querySelectorAll("[data-tier]").forEach(function (b) {
|
|
b.dataset.on = b === btn ? "1" : "0";
|
|
});
|
|
showBet();
|
|
});
|
|
});
|
|
|
|
// The chip you click is the chip that flies. It lands on the spot in the rail,
|
|
// which is where the price of the deck is stacked up before you pay it.
|
|
root.querySelectorAll("[data-chip]").forEach(function (btn) {
|
|
if (!btn.dataset.chip || btn.tagName !== "BUTTON") return;
|
|
btn.addEventListener("click", function () {
|
|
if (busy) return;
|
|
var d = parseInt(btn.dataset.chip, 10);
|
|
var money = window.PeteGames.view();
|
|
if (money && bet + d > money.chips) {
|
|
say(tableMsg, "You haven't got that many chips.", "bad");
|
|
return;
|
|
}
|
|
bet += d;
|
|
showBet();
|
|
|
|
var target = bet;
|
|
spot.amount = bet;
|
|
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
|
if (bet >= target) spot.render(target); // unless Clear got there first
|
|
});
|
|
});
|
|
});
|
|
|
|
root.querySelector("[data-bet-clear]").addEventListener("click", function () {
|
|
if (busy) return;
|
|
if (spot.amount) spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
|
bet = 0;
|
|
showBet();
|
|
});
|
|
|
|
startBtn.addEventListener("click", function () {
|
|
if (busy) return;
|
|
if (!tier) { say(tableMsg, "Pick a deal first.", "bad"); return; }
|
|
if (bet <= 0) { say(tableMsg, "Put something down for the deck.", "bad"); return; }
|
|
|
|
busy = true;
|
|
say(tableMsg, "");
|
|
var price = bet;
|
|
|
|
window.PeteGames.post("/api/games/solitaire/start", { bet: price, tier: tier })
|
|
.then(function (view) {
|
|
// The deck is *bought*: the chips on the spot go across to the house and
|
|
// do not come back. Then the spot starts again at nothing, and from here
|
|
// on it holds what the board has earned back.
|
|
return spot
|
|
.sweep(houseEl, price, { gap: 45, lift: 0.6 })
|
|
.then(function () {
|
|
bet = 0;
|
|
showBet();
|
|
window.PeteGames.apply(view);
|
|
return dealOut(view.solitaire);
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
say(tableMsg, err.message, "bad");
|
|
return window.PeteGames.refresh();
|
|
})
|
|
.then(function () { busy = false; });
|
|
});
|
|
|
|
// dealOut lays the board and flies every card onto it out of the stock, one at a
|
|
// time, across the columns — the way a deal actually goes down.
|
|
function dealOut(v) {
|
|
render(v);
|
|
if (reduced || !v) return Promise.resolve();
|
|
|
|
var from = stockEl.getBoundingClientRect();
|
|
var waits = [];
|
|
|
|
// The order a real deal goes in: one card to each column, then round again,
|
|
// starting a column further along each time.
|
|
var order = 0;
|
|
for (var row = 0; row < 7; row++) {
|
|
for (var col = row; col < 7; col++) {
|
|
var colEl = tableauEl.children[col];
|
|
var cardEl = colEl.children[row];
|
|
if (!cardEl) continue;
|
|
var now = cardEl.getBoundingClientRect();
|
|
waits.push(slide(cardEl, from.left - now.left, from.top - now.top, order * 34));
|
|
if (cardEl.dataset.face === "up") waits.push(turn(cardEl, order * 34));
|
|
order++;
|
|
}
|
|
}
|
|
return Promise.all(waits);
|
|
}
|
|
|
|
// ---- coming in ------------------------------------------------------------
|
|
|
|
// The money bar owns the first fetch. The table picks up whatever it found —
|
|
// including a board left mid-game by a reload or a redeploy, which comes back
|
|
// exactly as it was, right down to what it has banked.
|
|
var resumed = false;
|
|
window.PeteGames.onUpdate(function (v) {
|
|
if (!resumed) {
|
|
resumed = true;
|
|
if (v.solitaire) {
|
|
render(v.solitaire);
|
|
spot.render(v.solitaire.stands);
|
|
} else {
|
|
controls(null);
|
|
}
|
|
}
|
|
showBet();
|
|
});
|
|
})();
|