games: you buy the deck, and win it back a card at a time
Solitaire, Vegas rules — the only shape solitaire has ever had as a gambling game. You don't win or lose the deal: the stake buys the deck outright, and every card you get home to a foundation pays a fifty-second of the tier's multiple back. Cash the board whenever you like and keep what you've banked, so a board that has gone dead is a decision rather than a wall. No undo: the stake is spent the moment the deck is bought, and an undo would be a way to walk a losing board backwards until it wins. Three deals, and the two dials are the whole difficulty of Klondike. Patient draws one with unlimited passes and pays 1.4x, so it takes 38 cards home to get square. Vegas draws three, three times round, 2.2x, square at 24. Cutthroat draws three and gives you one pass, 3.4x, square at 16 — most of those boards never clear, and you're ahead long before they would. internal/games/klondike is the same pure reducer as the other two, and Pays() is one function for the same reason hangman's is. Two fuzzers hold the deck together: no sequence of moves can lose or duplicate a card, and the board stays well-formed. They earned their keep immediately — the first thing they caught was a recycle that reversed the waste. It flips as a block, so the card drawn first comes out first, and reversing it would have dealt a different game on every pass and quietly broken the seed in the audit log. The browser never sees the stock or a face-down card, which here is most of the deck rather than blackjack's one hole card: a column sends how many cards are under it, never which. The table re-renders and animates the difference. Blackjack plays back a script because a hand only ever grows at one end; solitaire moves runs from anywhere to anywhere and an auto-finish moves eleven cards at once, so a script of "append this card there" would be a second engine over here and it would be the one that's wrong. Instead the board on screen is always exactly the board the server says exists, and each card is played from where it just was to where it now is. The events supply only what a diff can't: where a newly-revealed card came from, and what the board is worth. The rules are mirrored in JS on purpose, and only to light up the columns a held card can go to. Being shown where a card goes is the game teaching you; being told no after you commit is the game scolding you. The server still decides, and a disagreement snaps the board back to what it says. Two things came out into the open rather than being copied, which is the rule this room runs on: casino-cards.js (the deck — faces, pips, the flip) and PeteFX.spot() (the pile of chips and the number under it, which now owns the rule that the number is a readout of the pile). Blackjack uses both. Not yet driven in a browser.
This commit is contained in:
@@ -44,11 +44,12 @@
|
||||
var spotTotalEl = root.querySelector("[data-spot-total]");
|
||||
var houseEl = root.querySelector("[data-house]");
|
||||
|
||||
// Nothing is bet until a chip is on the felt. The number in the panel is a
|
||||
// readout of the pile, so it starts where the pile does — at nothing — rather
|
||||
// than at a default stake nobody put down.
|
||||
// The spot owns the chips on the felt and the number under them — see PeteFX.
|
||||
// Nothing is bet until a chip is on it, so `bet` starts at nothing rather than
|
||||
// at a default stake nobody put down.
|
||||
var spot = FX.spot({ spot: spotEl, stack: stackEl, total: spotTotalEl });
|
||||
|
||||
var bet = 0; // what you're building between hands
|
||||
var staked = 0; // what is actually sitting on the spot right now
|
||||
var busy = false; // a request is in flight, or cards are still landing
|
||||
var hand = null; // the hand as the server last described it
|
||||
|
||||
@@ -68,194 +69,23 @@
|
||||
}
|
||||
|
||||
// ---- drawing --------------------------------------------------------------
|
||||
|
||||
var dealt = 0; // how many cards this table has put down, ever — the tilt seed
|
||||
|
||||
// cardEl builds one card. face === null means face-down: the card is dealt,
|
||||
// but this browser has not been told what it is.
|
||||
function cardEl(face) {
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "pete-card";
|
||||
wrap.dataset.face = face ? "up" : "down";
|
||||
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
||||
// The offset is per-card, so a card landing further left flies further.
|
||||
wrap.style.setProperty("--deal-x", "14rem");
|
||||
wrap.style.setProperty("--deal-y", "-6rem");
|
||||
// Where it comes to rest. A degree or two either way is the whole difference
|
||||
// between cards that were dealt onto a table and cards that were laid out in
|
||||
// a grid, and it costs one custom property.
|
||||
wrap.style.setProperty("--tilt", FX.jitter(dealt++, 2.4).toFixed(2) + "deg");
|
||||
|
||||
var inner = document.createElement("div");
|
||||
inner.className = "pete-card-inner";
|
||||
|
||||
var front = document.createElement("div");
|
||||
front.className = "pete-card-front";
|
||||
var back = document.createElement("div");
|
||||
back.className = "pete-card-back";
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
wrap.appendChild(inner);
|
||||
if (face) paintFace(front, face);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---- the face ------------------------------------------------------------
|
||||
//
|
||||
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
|
||||
// span — and at the size a card actually is, a suit character renders as a
|
||||
// speck: the shape is whatever font happened to answer, it doesn't scale, and
|
||||
// it can't be positioned to the half-row a real pip layout needs.
|
||||
//
|
||||
// So each face is one SVG on a 100×140 field (the proportions of a real card),
|
||||
// with the suits as vector shapes. Everything below is coordinates on that
|
||||
// field, which is why the pips land where a printed deck puts them instead of
|
||||
// where a flexbox felt like putting them.
|
||||
// The deck itself — the faces, the pips, the flip — is PeteCards, shared with
|
||||
// every other table in the room. Here a card is always dealt out of the shoe
|
||||
// and always lands with a degree or two of tilt on it, which are this table's
|
||||
// two opinions about a card and the only ones it has.
|
||||
|
||||
var SUIT_ART = {
|
||||
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
|
||||
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
|
||||
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
|
||||
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
|
||||
'<path d="M44 60h12l7 36H37Z"/></g>',
|
||||
};
|
||||
var CARDS = window.PeteCards;
|
||||
|
||||
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
|
||||
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
|
||||
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
|
||||
// them, which is the whole reason this is a table of coordinates and not a
|
||||
// grid. Anything below the middle is printed upside down, so it is.
|
||||
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
|
||||
var L = 30, C = 50, Rr = 70; // the three columns
|
||||
|
||||
var PIPS = {
|
||||
"A": [[C, 70, 2.1]],
|
||||
"2": [[C, R[1]], [C, R[7]]],
|
||||
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
|
||||
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
|
||||
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
|
||||
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||
};
|
||||
|
||||
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
|
||||
|
||||
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
|
||||
// below the middle of the card.
|
||||
function pipAt(suit, x, y, scale) {
|
||||
var s = (scale || 1) * 0.17;
|
||||
var turn = y > 70 ? " rotate(180 50 50)" : "";
|
||||
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
||||
SUIT_ART[suit] + "</g>";
|
||||
}
|
||||
|
||||
// The corner index: rank over suit. Printed in both corners, the second one
|
||||
// upside down, which is what lets you read a card from a fanned hand.
|
||||
function index(face) {
|
||||
var g =
|
||||
'<g>' +
|
||||
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
||||
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
||||
"</g>";
|
||||
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
|
||||
}
|
||||
|
||||
// paintFace draws the card. The dealer's cards and yours use the same face,
|
||||
// because they came out of the same shoe.
|
||||
function paintFace(front, face) {
|
||||
front.dataset.red = face.red ? "1" : "0";
|
||||
|
||||
var body = "";
|
||||
if (COURT[face.rank]) {
|
||||
// Court cards: a framed panel, the suit above the letter and again below it
|
||||
// the other way up. A real court mirrors a *figure*; mirroring a letter just
|
||||
// stacks two of them into a blob, which is exactly what the first attempt
|
||||
// did. No portrait either — a drawn king would fight the room, and this
|
||||
// reads instantly at the size a card actually is.
|
||||
body =
|
||||
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
||||
pipAt(face.suit, 50, 38, 0.95) +
|
||||
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
||||
pipAt(face.suit, 50, 102, 0.95);
|
||||
} else {
|
||||
var spots = PIPS[face.rank] || [];
|
||||
for (var i = 0; i < spots.length; i++) {
|
||||
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
|
||||
}
|
||||
}
|
||||
|
||||
front.innerHTML =
|
||||
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
||||
'role="img" aria-label="' + ariaFor(face) + '">' + index(face) + body + "</svg>";
|
||||
}
|
||||
|
||||
// "A♠" is not something a screen reader should be asked to pronounce.
|
||||
function ariaFor(face) {
|
||||
var SUITS = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
||||
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
||||
var suit = SUITS[face.suit];
|
||||
return suit ? name + " of " + suit : face.label;
|
||||
}
|
||||
|
||||
// turnOver flips a face-down card up, now that we've been told what it is.
|
||||
function turnOver(wrap, face) {
|
||||
if (!wrap) return;
|
||||
paintFace(wrap.querySelector(".pete-card-front"), face);
|
||||
wrap.dataset.face = "up";
|
||||
}
|
||||
function cardEl(face) { return CARDS.el(face); }
|
||||
var turnOver = CARDS.turnOver;
|
||||
|
||||
// ---- the money on the felt -------------------------------------------------
|
||||
//
|
||||
// `staked` is what the spot is holding. Every path that changes it also moves
|
||||
// chips to say so, so the two can't come apart: renderStack draws the pile,
|
||||
// and the fly* calls are what put it there.
|
||||
|
||||
function renderStack(amount) {
|
||||
staked = amount || 0;
|
||||
stackEl.innerHTML = "";
|
||||
spotEl.dataset.live = staked > 0 ? "1" : "0";
|
||||
|
||||
if (!staked) {
|
||||
spotTotalEl.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
FX.chipsFor(staked).forEach(function (d, i) {
|
||||
var c = FX.disc(d);
|
||||
c.style.setProperty("--i", i);
|
||||
c.style.setProperty("--spin", FX.jitter(i, 12).toFixed(1) + "deg");
|
||||
c.style.animationDelay = pace(i * 40) + "ms";
|
||||
stackEl.appendChild(c);
|
||||
});
|
||||
spotTotalEl.textContent = staked.toLocaleString();
|
||||
spotTotalEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// pour throws a run of chips from one place to another and grows the pile on
|
||||
// the spot as each one lands — by the value of the chip that landed, so the
|
||||
// total under the pile counts up the way the chips do. The last chip carries
|
||||
// the remainder, because chipsFor caps how many chips it will make you watch
|
||||
// and the pile still has to end on the real number.
|
||||
function pour(from, to, amount, opts) {
|
||||
if (amount <= 0) return Promise.resolve();
|
||||
var base = staked;
|
||||
var chips = FX.chipsFor(amount, 8);
|
||||
var run = 0;
|
||||
return FX.flyMany(from, to, chips, Object.assign({
|
||||
onLand: function (d, i) {
|
||||
run += d;
|
||||
renderStack(base + (i === chips.length - 1 ? amount : run));
|
||||
},
|
||||
}, opts || {}));
|
||||
}
|
||||
|
||||
// stake moves chips from your pile onto the spot: the bet you build before a
|
||||
// deal, and the second bet a double puts down beside it.
|
||||
function stake(amount, from) {
|
||||
return pour(from || purseEl, spotEl, amount);
|
||||
return spot.pour(from || purseEl, amount);
|
||||
}
|
||||
|
||||
// settleChips is what the felt does about the outcome, after the cards have
|
||||
@@ -268,25 +98,17 @@
|
||||
|
||||
if (payout <= 0) {
|
||||
// The house takes it. The stack goes to the rack and doesn't come back.
|
||||
var lost = FX.chipsFor(final.bet, 8);
|
||||
var chain = FX.flyMany(spotEl, houseEl, lost, { gap: 45, lift: 0.6, fade: true });
|
||||
renderStack(0);
|
||||
return chain;
|
||||
return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
|
||||
}
|
||||
|
||||
// The house pays first, into the spot beside your stake, so you watch the
|
||||
// winnings arrive on top of the bet that earned them.
|
||||
var pay = pour(houseEl, spotEl, back, { gap: 60 });
|
||||
|
||||
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
||||
// does the number in the bar move.
|
||||
return pay
|
||||
return spot
|
||||
.pour(houseEl, back, { gap: 60 })
|
||||
.then(function () { return wait(back > 0 ? 380 : 200); })
|
||||
.then(function () {
|
||||
var home = FX.flyMany(spotEl, purseEl, FX.chipsFor(payout, 8), { gap: 40, lift: 0.8 });
|
||||
renderStack(0);
|
||||
return home;
|
||||
});
|
||||
// Paid, then swept up: the whole lot comes back to your pile, and only then
|
||||
// does the number in the bar move.
|
||||
.then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
|
||||
}
|
||||
|
||||
function totals(v) {
|
||||
@@ -312,12 +134,12 @@
|
||||
function paint(v) {
|
||||
dealerEl.innerHTML = "";
|
||||
playerEl.innerHTML = "";
|
||||
if (!v) { setPhase(null); renderStack(0); return; }
|
||||
if (!v) { setPhase(null); spot.render(0); return; }
|
||||
|
||||
v.player.forEach(function (c) { playerEl.appendChild(cardEl(c)); });
|
||||
v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
|
||||
if (v.hole) dealerEl.appendChild(cardEl(null));
|
||||
renderStack(v.phase === "done" ? 0 : v.bet);
|
||||
spot.render(v.phase === "done" ? 0 : v.bet);
|
||||
totals(v);
|
||||
setPhase(v);
|
||||
}
|
||||
@@ -386,8 +208,8 @@
|
||||
// first, and a deal whose bet was typed rather than stacked (you kept last
|
||||
// hand's number and just pressed Deal). Either way the chips go down before
|
||||
// the card they're buying does.
|
||||
if (final && final.bet > staked) {
|
||||
var extra = final.bet - staked;
|
||||
if (final && final.bet > spot.amount) {
|
||||
var extra = final.bet - spot.amount;
|
||||
chain = chain.then(function () { return stake(extra); });
|
||||
}
|
||||
|
||||
@@ -499,7 +321,7 @@
|
||||
say(err.message, "bad");
|
||||
// Whatever we thought was on the felt, the server is the authority on it.
|
||||
return window.PeteGames.refresh().then(function (v) {
|
||||
if (v && !v.hand) renderStack(0);
|
||||
if (v && !v.hand) spot.render(0);
|
||||
});
|
||||
})
|
||||
.then(function () { busy = false; });
|
||||
@@ -531,12 +353,12 @@
|
||||
|
||||
// The chip you clicked is the chip that flies: same colour, same size, off
|
||||
// the button and onto the felt. The pile only grows once it gets there —
|
||||
// but `staked` moves now, so a Deal pressed mid-flight still knows the chip
|
||||
// is on its way and doesn't put a second one down.
|
||||
// but the spot's total moves now, so a Deal pressed mid-flight still knows
|
||||
// the chip is on its way and doesn't put a second one down.
|
||||
var target = bet;
|
||||
staked = bet;
|
||||
spot.amount = bet;
|
||||
FX.fly(btn, spotEl, { denom: d }).then(function () {
|
||||
if (bet >= target) renderStack(target); // unless Clear got there first
|
||||
if (bet >= target) spot.render(target); // unless Clear got there first
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -544,10 +366,9 @@
|
||||
var clearBtn = root.querySelector("[data-bet-clear]");
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", function () {
|
||||
if (busy || !staked) { bet = 0; showBet(); return; }
|
||||
FX.flyMany(spotEl, purseEl, FX.chipsFor(staked, 8), { gap: 40, lift: 0.7 });
|
||||
if (busy || !spot.amount) { bet = 0; showBet(); return; }
|
||||
spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
|
||||
bet = 0;
|
||||
renderStack(0);
|
||||
showBet();
|
||||
});
|
||||
}
|
||||
|
||||
172
internal/web/static/js/casino-cards.js
Normal file
172
internal/web/static/js/casino-cards.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// The deck, as the room draws it.
|
||||
//
|
||||
// A card is drawn, not typed. The first attempt set the pips as text — "♠" in a
|
||||
// span — and at the size a card actually is, a suit character renders as a speck:
|
||||
// the shape is whatever font happened to answer, it doesn't scale, and it can't
|
||||
// be positioned to the half-row a real pip layout needs.
|
||||
//
|
||||
// So each face is one SVG on a 100×140 field (the proportions of a real card),
|
||||
// with the suits as vector shapes. Everything below is coordinates on that field,
|
||||
// which is why the pips land where a printed deck puts them instead of where a
|
||||
// flexbox felt like putting them.
|
||||
//
|
||||
// This started life inside blackjack.js. It's out here because solitaire deals
|
||||
// off the same deck, and the second table in a casino is exactly the moment a
|
||||
// copied card renderer becomes two card renderers that drift.
|
||||
//
|
||||
// Exposed as window.PeteCards. Nothing in here knows what game is being played.
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var SUIT_ART = {
|
||||
"♠": '<path d="M50 6C50 6 16 34 16 58a17 17 0 0 0 28 13c-1 12-5 19-12 25h36c-7-6-11-13-12-25a17 17 0 0 0 28-13C84 34 50 6 50 6Z"/>',
|
||||
"♥": '<path d="M50 96C50 96 8 66 8 38A22 22 0 0 1 50 24 22 22 0 0 1 92 38c0 28-42 58-42 58Z"/>',
|
||||
"♦": '<path d="M50 4 88 50 50 96 12 50Z"/>',
|
||||
"♣": '<g><circle cx="50" cy="28" r="19"/><circle cx="24" cy="62" r="19"/><circle cx="76" cy="62" r="19"/>' +
|
||||
'<path d="M44 60h12l7 36H37Z"/></g>',
|
||||
};
|
||||
|
||||
// Pip layouts, the way a real deck lays them out — which is not "N suits in a
|
||||
// row". [x, y] on the 100×140 field. The seven canonical rows sit at y = 27,
|
||||
// 41, 56, 70, 84, 99, 113; sevens, eights and tens carry a pip *between* two of
|
||||
// them, which is the whole reason this is a table of coordinates and not a
|
||||
// grid. Anything below the middle is printed upside down, so it is.
|
||||
var R = [0, 27, 41.4, 55.7, 70, 84.3, 98.6, 113]; // 1-indexed, R[4] is the middle
|
||||
var L = 30, C = 50, Rr = 70; // the three columns
|
||||
|
||||
var PIPS = {
|
||||
"A": [[C, 70, 2.1]],
|
||||
"2": [[C, R[1]], [C, R[7]]],
|
||||
"3": [[C, R[1]], [C, R[4]], [C, R[7]]],
|
||||
"4": [[L, R[1]], [Rr, R[1]], [L, R[7]], [Rr, R[7]]],
|
||||
"5": [[L, R[1]], [Rr, R[1]], [C, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"6": [[L, R[1]], [Rr, R[1]], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"7": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [L, R[7]], [Rr, R[7]]],
|
||||
"8": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[4]], [Rr, R[4]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||
"9": [[L, R[1]], [Rr, R[1]], [L, R[3]], [Rr, R[3]], [C, R[4]], [L, R[5]], [Rr, R[5]], [L, R[7]], [Rr, R[7]]],
|
||||
"10": [[L, R[1]], [Rr, R[1]], [C, 48.5], [L, R[3]], [Rr, R[3]], [L, R[5]], [Rr, R[5]], [C, 91.5], [L, R[7]], [Rr, R[7]]],
|
||||
};
|
||||
|
||||
var COURT = { "J": "Jack", "Q": "Queen", "K": "King" };
|
||||
var SUIT_NAMES = { "♠": "spades", "♥": "hearts", "♦": "diamonds", "♣": "clubs" };
|
||||
|
||||
// One pip: the suit art, scaled and dropped at [x, y], turned over if it sits
|
||||
// below the middle of the card.
|
||||
function pipAt(suit, x, y, scale) {
|
||||
var s = (scale || 1) * 0.17;
|
||||
var turn = y > 70 ? " rotate(180 50 50)" : "";
|
||||
return '<g transform="translate(' + x + ' ' + y + ') scale(' + s + ') translate(-50 -50)' + turn + '">' +
|
||||
SUIT_ART[suit] + "</g>";
|
||||
}
|
||||
|
||||
// The corner index: rank over suit. Printed in both corners, the second one
|
||||
// upside down, which is what lets you read a card from a fanned hand — and in
|
||||
// solitaire, from a column where all you can see is the top eighth of it.
|
||||
function index(face) {
|
||||
var g =
|
||||
'<g>' +
|
||||
'<text x="12" y="24" class="pete-card-idx">' + face.rank + "</text>" +
|
||||
'<g transform="translate(12 36) scale(0.13) translate(-50 -50)">' + SUIT_ART[face.suit] + "</g>" +
|
||||
"</g>";
|
||||
return g + '<g transform="rotate(180 50 70)">' + g + "</g>";
|
||||
}
|
||||
|
||||
// paint draws the face. Every table uses this one, because they all deal out of
|
||||
// the same deck.
|
||||
function paint(front, face) {
|
||||
front.dataset.red = face.red ? "1" : "0";
|
||||
|
||||
var body = "";
|
||||
if (COURT[face.rank]) {
|
||||
// Court cards: a framed panel, the suit above the letter and again below it
|
||||
// the other way up. A real court mirrors a *figure*; mirroring a letter just
|
||||
// stacks two of them into a blob, which is exactly what the first attempt
|
||||
// did. No portrait either — a drawn king would fight the room, and this
|
||||
// reads instantly at the size a card actually is.
|
||||
body =
|
||||
'<rect x="20" y="22" width="60" height="96" rx="6" class="pete-card-panel"/>' +
|
||||
pipAt(face.suit, 50, 38, 0.95) +
|
||||
'<text x="50" y="82" class="pete-card-court">' + face.rank + "</text>" +
|
||||
pipAt(face.suit, 50, 102, 0.95);
|
||||
} else {
|
||||
var spots = PIPS[face.rank] || [];
|
||||
for (var i = 0; i < spots.length; i++) {
|
||||
body += pipAt(face.suit, spots[i][0], spots[i][1], spots[i][2]);
|
||||
}
|
||||
}
|
||||
|
||||
front.innerHTML =
|
||||
'<svg class="pete-card-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg" ' +
|
||||
'role="img" aria-label="' + aria(face) + '">' + index(face) + body + "</svg>";
|
||||
}
|
||||
|
||||
// "A♠" is not something a screen reader should be asked to pronounce.
|
||||
function aria(face) {
|
||||
var name = COURT[face.rank] || (face.rank === "A" ? "Ace" : face.rank);
|
||||
var suit = SUIT_NAMES[face.suit];
|
||||
return suit ? name + " of " + suit : face.label;
|
||||
}
|
||||
|
||||
var dealt = 0; // how many cards this page has put down, ever — the tilt seed
|
||||
|
||||
// el builds one card. face === null means face-down: the card is on the table,
|
||||
// but this browser has not been told what it is.
|
||||
//
|
||||
// opts.deal — fly it in from the shoe (blackjack). Solitaire turns this off:
|
||||
// it animates its own cards from wherever they actually came from,
|
||||
// and a board that re-renders would otherwise re-deal itself out of
|
||||
// the corner on every single move.
|
||||
// opts.tilt — a degree or two of resting angle. A dealt hand wants it; a
|
||||
// solitaire column does not, because thirteen tilted cards stacked
|
||||
// an eighth of an inch apart read as a mistake rather than as a
|
||||
// hand that was handled.
|
||||
function el(face, opts) {
|
||||
opts = opts || {};
|
||||
var deal = opts.deal !== false;
|
||||
var tilt = opts.tilt !== false;
|
||||
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "pete-card";
|
||||
wrap.dataset.face = face ? "up" : "down";
|
||||
if (face) wrap.dataset.key = face.label; // one deck, so the label is an id
|
||||
|
||||
if (deal) {
|
||||
// Every card flies out of the shoe, which sits in the top-right of the felt.
|
||||
wrap.style.setProperty("--deal-x", "14rem");
|
||||
wrap.style.setProperty("--deal-y", "-6rem");
|
||||
} else {
|
||||
wrap.style.animation = "none";
|
||||
}
|
||||
wrap.style.setProperty("--tilt", tilt ? window.PeteFX.jitter(dealt++, 2.4).toFixed(2) + "deg" : "0deg");
|
||||
|
||||
var inner = document.createElement("div");
|
||||
inner.className = "pete-card-inner";
|
||||
|
||||
var front = document.createElement("div");
|
||||
front.className = "pete-card-front";
|
||||
var back = document.createElement("div");
|
||||
back.className = "pete-card-back";
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
wrap.appendChild(inner);
|
||||
if (face) paint(front, face);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// turnOver flips a face-down card up, now that we've been told what it is.
|
||||
function turnOver(wrap, face) {
|
||||
if (!wrap) return;
|
||||
paint(wrap.querySelector(".pete-card-front"), face);
|
||||
wrap.dataset.face = "up";
|
||||
wrap.dataset.key = face.label;
|
||||
}
|
||||
|
||||
window.PeteCards = {
|
||||
el: el,
|
||||
paint: paint,
|
||||
turnOver: turnOver,
|
||||
aria: aria,
|
||||
art: SUIT_ART,
|
||||
};
|
||||
})();
|
||||
@@ -144,6 +144,78 @@
|
||||
return Promise.all(each);
|
||||
}
|
||||
|
||||
// A bet spot: the pile of chips sitting on it, and the number printed under
|
||||
// the pile.
|
||||
//
|
||||
// The rule the whole room is built on lives in here, which is why it's one
|
||||
// object and not two variables on a table: **the number is a readout of the
|
||||
// pile, never the other way round.** There is no way to change one without the
|
||||
// other, so a settled game can't leave "your bet: 300" printed over an empty
|
||||
// circle, and a payout can't be counted before the chips that justify it have
|
||||
// landed.
|
||||
//
|
||||
// els: {spot, stack, total}. Blackjack's spot holds the stake; solitaire's
|
||||
// holds what you've banked, which grows a card at a time. Same object.
|
||||
function spot(els) {
|
||||
var api = {
|
||||
// What the pile is holding. Written by render, and readable by a table that
|
||||
// needs to know whether a chip is already on its way down.
|
||||
amount: 0,
|
||||
|
||||
render: function (n) {
|
||||
api.amount = n || 0;
|
||||
els.stack.innerHTML = "";
|
||||
if (els.spot) els.spot.dataset.live = api.amount > 0 ? "1" : "0";
|
||||
if (!api.amount) {
|
||||
if (els.total) els.total.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
chipsFor(api.amount).forEach(function (d, i) {
|
||||
var c = disc(d);
|
||||
c.style.setProperty("--i", i);
|
||||
c.style.setProperty("--spin", jitter(i, 12).toFixed(1) + "deg");
|
||||
c.style.animationDelay = (reduced ? 0 : i * 40) + "ms";
|
||||
els.stack.appendChild(c);
|
||||
});
|
||||
if (els.total) {
|
||||
els.total.textContent = api.amount.toLocaleString();
|
||||
els.total.classList.remove("hidden");
|
||||
}
|
||||
},
|
||||
|
||||
// pour throws a run of chips onto the spot and grows the pile as each one
|
||||
// lands — by the value of the chip that landed, so the total under the pile
|
||||
// counts up the way the chips do. The last chip carries the remainder,
|
||||
// because chipsFor caps how many chips it will make you watch and the pile
|
||||
// still has to end on the real number.
|
||||
pour: function (from, amount, opts) {
|
||||
if (amount <= 0) return Promise.resolve();
|
||||
var base = api.amount;
|
||||
var chips = chipsFor(amount, 8);
|
||||
var run = 0;
|
||||
return flyMany(from, els.spot, chips, Object.assign({
|
||||
onLand: function (d, i) {
|
||||
run += d;
|
||||
api.render(base + (i === chips.length - 1 ? amount : run));
|
||||
},
|
||||
}, opts || {}));
|
||||
},
|
||||
|
||||
// sweep sends chips off the spot to somewhere else — your pile, or the
|
||||
// house's rack. The spot is emptied *now* rather than when they land, so
|
||||
// nothing that is already in the air can be bet a second time.
|
||||
sweep: function (to, amount, opts) {
|
||||
var n = amount == null ? api.amount : amount;
|
||||
var left = api.amount - n;
|
||||
if (n <= 0) return Promise.resolve();
|
||||
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;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
// burst: confetti out of a point. Saved for the things worth celebrating.
|
||||
function burst(target, opts) {
|
||||
if (reduced) return Promise.resolve();
|
||||
@@ -242,6 +314,7 @@
|
||||
jitter: jitter,
|
||||
fly: fly,
|
||||
flyMany: flyMany,
|
||||
spot: spot,
|
||||
burst: burst,
|
||||
count: count,
|
||||
centre: centre,
|
||||
|
||||
729
internal/web/static/js/solitaire.js
Normal file
729
internal/web/static/js/solitaire.js
Normal file
@@ -0,0 +1,729 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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's the one that gets the confetti.
|
||||
if (v.outcome === "cleared") FX.burst(verdictEl, { count: 40 });
|
||||
}
|
||||
|
||||
// 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);
|
||||
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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user