// 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 = '' + glyph + ""; } 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/' + FULL + ""; perCardEl.textContent = "—"; breakEvenEl.textContent = ""; return; } homeEl.innerHTML = v.home + '/' + FULL + ""; 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'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 // 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(); }); })();