solitaire: fly the cards home instead of blinking them there

A foundation only ever draws its top card, so the FLIP diff had nothing to
animate for the fifty-one cards buried underneath: an auto-finish was every
card vanishing at once, four kings stranded in the tableau row, and the
foundation flashes firing on empty slots.

Cards bound for a foundation now fly as ghosts, lifted out of the old DOM
into a fixed layer so the board underneath can re-render in one go while
they are still in the air. The card that lands is held invisible until its
ghost arrives, so the destination doesn't show the ending first.

The home counter was jumping straight to 52/52 over a board with fifty-one
cards still travelling. It walks up behind them now, on the same step ladder
as the ghosts, the flashes and the sound.
This commit is contained in:
prosolis
2026-07-17 23:41:26 -07:00
parent 2593b11112
commit 19255d933a
3 changed files with 131 additions and 10 deletions
+10
View File
@@ -1340,6 +1340,16 @@ html[data-phase="night"] {
/* An empty place a card could be: the stock when it's spent, a foundation
waiting for its ace, a column waiting for a king. Same footprint as a card,
so nothing on the board reflows when one empties. */
/* Where cards go home. Solitaire lifts a copy of every card bound for a
foundation into here so it can keep flying while the board underneath it
redraws in one go. It never takes a click and it never scrolls. */
.pete-flight-layer {
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
}
.pete-slot {
position: relative;
display: grid;
File diff suppressed because one or more lines are too long
+120 -9
View File
@@ -240,20 +240,45 @@
// 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) {
//
// `home` overrides how many cards it says are home, and nothing else. It is
// there so the count can walk up behind the cards instead of arriving with
// them: see tally. It only ever names a count the board has genuinely passed
// through on its way to v.home, so the meter is never quoting a board that
// didn't exist.
function meter(v, home) {
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>";
var n = home === undefined ? v.home : home;
homeEl.innerHTML = n + '<span class="text-white/40">/' + FULL + "</span>";
perCardEl.textContent = "+" + v.per_card.toFixed(1);
breakEvenEl.textContent =
v.home >= v.break_even
n >= 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";
: v.break_even - n + " more to break even";
meterEl.dataset.cold = n === 0 ? "1" : "0";
}
// tally walks the home count up one card at a time, in step with the cards
// actually landing. The chips on this table are already held back 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 — and the card count was
// the one number still jumping straight to the answer while fifty-one cards
// were visibly still in the air.
function tally(v, arrivals, from) {
if (!v || !arrivals.length) return;
var order = arrivals.slice().sort(function (a, b) { return a.at - b.at; });
meter(v, from);
order.forEach(function (a, i) {
setTimeout(function () { meter(v, from + i + 1); }, a.at);
});
// Whatever the steps added up to, the board is the board: the last word goes
// to the server's own count rather than to this file's running total.
setTimeout(function () { meter(v); }, order[order.length - 1].at);
}
function controls(v) {
@@ -300,12 +325,13 @@
}
// animate plays every card from where it was to where it is.
function animate(before, plan) {
function animate(before, plan, flying) {
if (reduced) return Promise.resolve();
var waits = [];
root.querySelectorAll(".pete-card[data-key]").forEach(function (el) {
var key = el.dataset.key;
if (flying && flying[key]) return; // a ghost of it is already making the trip
var now = el.getBoundingClientRect();
var was = before[key];
var delay = plan.delays[key] || 0;
@@ -336,6 +362,86 @@
return Promise.all(waits);
}
// Cards going home are the one move the after-picture can't describe. A
// foundation only ever draws its top card, so an auto-finish that sends eleven
// cards home is ten cards blinking out of existence and one appearing — which
// is exactly what a finished board used to look like.
//
// So they fly as ghosts: copies lifted out of the old DOM into a fixed layer,
// where they can still be in the air while the board underneath them
// re-renders all at once. The real card that lands on each foundation is held
// invisible until its ghost gets there, so the destination doesn't give away
// the ending before the journey is over.
function homeOut(events) {
var none = { flying: {}, arrivals: [], done: Promise.resolve() };
if (reduced) return none;
var flights = [], at = 0;
(events || []).forEach(function (e) {
if (e.kind === "move") { at += STEP_MS; return; }
if (e.kind !== "home") return;
var delay = at;
at += STEP_MS;
if (!e.to) return;
var dest = foundEl.querySelector('[data-pile="' + e.to + '"]');
if (!dest) return;
var to = dest.getBoundingClientRect();
(e.cards || []).forEach(function (c) {
var el = root.querySelector('.pete-card[data-key="' + c.label + '"]');
if (!el) return;
flights.push({ label: c.label, pile: e.to, delay: delay, from: el.getBoundingClientRect(), to: to, el: el });
});
});
if (!flights.length) return none;
var layer = document.createElement("div");
layer.className = "pete-flight-layer";
document.body.appendChild(layer);
var flying = {}, arrivals = [], waits = [];
flights.forEach(function (f) {
flying[f.label] = true;
arrivals.push({ label: f.label, pile: f.pile, at: f.delay + MOVE_MS });
var ghost = f.el.cloneNode(true);
ghost.style.position = "fixed";
ghost.style.left = f.from.left + "px";
ghost.style.top = f.from.top + "px";
ghost.style.width = f.from.width + "px";
ghost.style.height = f.from.height + "px";
ghost.style.margin = "0";
ghost.style.animation = "none";
layer.appendChild(ghost);
waits.push(
ghost.animate(
[
{ transform: "none" },
{ transform: "translate(" + (f.to.left - f.from.left) + "px," + (f.to.top - f.from.top) + "px)" },
],
{ duration: MOVE_MS, delay: f.delay, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" }
).finished.catch(noop)
);
});
return {
flying: flying,
arrivals: arrivals,
done: Promise.all(waits).then(function () { layer.remove(); }),
};
}
// land holds each freshly-rendered foundation card back until the ghost of it
// has finished its flight, then swaps one for the other.
function land(arrivals) {
arrivals.forEach(function (a) {
var el = foundEl.querySelector('[data-pile="' + a.pile + '"] .pete-card[data-key="' + a.label + '"]');
if (!el) return;
el.style.visibility = "hidden";
setTimeout(function () { el.style.visibility = ""; }, a.at);
});
}
function slide(el, dx, dy, delay) {
return el.animate(
[{ transform: "translate(" + dx + "px," + dy + "px)" }, { transform: "none" }],
@@ -407,8 +513,9 @@
// 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.
// silence, so this counts its way through the script exactly as planOf,
// homeOut 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) {
@@ -612,11 +719,15 @@
return pre
.then(function () {
var before = snapshot();
var wasHome = board ? board.home : 0;
var home = homeOut(events);
render(v);
land(home.arrivals);
tally(v, home.arrivals, wasHome);
var plan = planOf(events);
flashHome(events);
noises(events);
return animate(before, plan);
return Promise.all([animate(before, plan, home.flying), home.done]);
})
.then(function () {
if (!v) { money(); return; }