solitaire: let cards be dragged, not only tapped

Tapping stays exactly as it was. A press that travels more than a few
pixels becomes a drag instead, and both paths go through the same pick()
and accepts(), so what lights up and what a move means never depends on
how you did it. The run follows the pointer as a copy while the real
cards stay on the felt, faded, and letting go over nothing keeps the run
in your hand with the targets still lit.

Capture happens when the press becomes a drag, not on pointerdown:
capturing early retargets the click that a tap ends with, and tapping
quietly stops working.
This commit is contained in:
prosolis
2026-07-18 09:03:08 -07:00
parent 19255d933a
commit 8aa3e762ca
4 changed files with 186 additions and 2 deletions
+46
View File
@@ -1498,6 +1498,52 @@ html[data-phase="night"] {
/* A move that won't go. Said in the one language a board can speak. */ /* A move that won't go. Said in the one language a board can speak. */
.pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); } .pete-nope { animation: pete-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
/* Dragging. A press on a card that can be lifted belongs to the card and not to
the page, or a drag down a column scrolls the board instead of moving a run. */
.pete-card[data-live="1"] { touch-action: none; }
[data-solitaire][data-dragging] { cursor: grabbing; user-select: none; }
[data-solitaire][data-dragging] .pete-card[data-live="1"]:hover .pete-card-front {
filter: none;
}
/* The run you lifted stays on the felt, faded: where it came from, and where it
goes back to if you let go over nothing. */
[data-solitaire][data-dragging] .pete-card[data-held="1"] {
opacity: 0.3;
transform: none;
}
[data-solitaire][data-dragging] .pete-card[data-held="1"] .pete-card-front {
box-shadow: none;
}
/* The copy under your hand. It doesn't answer to hit tests — the felt beneath it
has to be the thing you're pointing at. */
.pete-drag {
position: fixed;
left: 0;
top: 0;
z-index: 60;
pointer-events: none;
filter: drop-shadow(0 14px 22px rgba(0, 0, 0, 0.45));
transform: translate3d(-999px, -999px, 0);
}
.pete-drag .pete-card { position: relative; }
.pete-drag[data-ok="1"] .pete-card-front {
box-shadow: 0 0 0 3px rgba(242, 181, 61, 0.9);
}
/* The pile you're actually over, told apart from the ones that would merely
take it. */
.pete-slot[data-over="1"],
.pete-col[data-over="1"] .pete-slot,
.pete-col[data-over="1"] .pete-card:last-child .pete-card-front {
border-color: rgba(242, 181, 61, 1);
box-shadow: 0 0 0 4px rgba(242, 181, 61, 0.75);
}
.pete-slot[data-over="1"],
.pete-col[data-over="1"] .pete-slot {
background: rgba(242, 181, 61, 0.24);
}
/* A card arriving on a foundation lands with a flash: it is the only move in /* A card arriving on a foundation lands with a flash: it is the only move in
the game that pays you, so it is the only one that gets a noise. */ the game that pays you, so it is the only one that gets a noise. */
.pete-home-flash { animation: pete-home 0.5s ease-out; } .pete-home-flash { animation: pete-home 0.5s ease-out; }
File diff suppressed because one or more lines are too long
+138
View File
@@ -603,6 +603,7 @@
// something else. Which means you never have to put a card down before choosing // something else. Which means you never have to put a card down before choosing
// a different one. // a different one.
root.querySelector(".pete-felt").addEventListener("click", function (e) { root.querySelector(".pete-felt").addEventListener("click", function (e) {
if (swallowClick) { swallowClick = false; return; } // that was the end of a drag
if (busy || !board || board.phase !== "playing") return; if (busy || !board || board.phase !== "playing") return;
if (e.target.closest("[data-stock]")) return; // the stock has its own handler if (e.target.closest("[data-stock]")) return; // the stock has its own handler
@@ -633,6 +634,143 @@
} }
}); });
// ---- dragging --------------------------------------------------------------
//
// Tapping is the friendly way to play and it stays exactly as it was. Dragging
// is the *other* way, and some hands want it: picking a run up and putting it
// down is one gesture rather than two, and it's the one every physical deck of
// cards has taught. The two share everything below the surface — a drag picks a
// run up with the same pick() and asks the same accepts() — so what lights up
// and what a move means never depends on how you did it.
//
// Nothing happens until the pointer has actually moved. Under the threshold this
// is a tap and the click handler above gets it untouched; over it, the click that
// browsers fire after a drag is swallowed so a drag never also counts as a tap.
var SLOP = 6; // px of travel before a press becomes a drag
var drag = null; // {pile, idx, x0, y0, dx, dy, ghost, live}
var swallowClick = false;
// ghost is the run in your hand, drawn once and moved with the pointer. It's a
// copy: the real cards stay on the felt, faded, so you can see where you lifted
// from and where you'd be putting it back.
function ghostFor(cards, from) {
var felt = getComputedStyle(root.querySelector(".pete-felt"));
var g = document.createElement("div");
g.className = "pete-drag";
["--card-w", "--card-h", "--fan-up", "--fan-down"].forEach(function (v) {
g.style.setProperty(v, felt.getPropertyValue(v));
});
var col = document.createElement("div");
col.className = "pete-col";
cards.forEach(function (c) {
var el = CARDS.el(c, { deal: false, tilt: false });
col.appendChild(el);
});
g.appendChild(col);
g.style.width = from.width + "px";
document.body.appendChild(g);
return g;
}
function dragTo(x, y) {
drag.ghost.style.transform =
"translate3d(" + (x - drag.dx) + "px," + (y - drag.dy) + "px,0)";
// What's under the pointer, and would it take this? The ghost doesn't answer
// to hit tests, so this sees the felt underneath it.
var under = document.elementFromPoint(x, y);
var pileEl = under && under.closest ? under.closest("[data-pile]") : null;
var pile = pileEl ? pileEl.dataset.pile : null;
if (pileEl && pileEl.classList.contains("pete-card")) {
pileEl = pileEl.parentElement && pileEl.parentElement.closest("[data-pile]");
}
var ok = pile && pile !== drag.pile && accepts(pile, held.cards);
root.querySelectorAll('[data-over="1"]').forEach(function (el) { delete el.dataset.over; });
if (ok && pileEl) pileEl.dataset.over = "1";
drag.ghost.dataset.ok = ok ? "1" : "0";
drag.target = ok ? pile : null;
drag.targetEl = ok ? pileEl : null;
}
function dragEnd(commit) {
if (!drag) return;
var d = drag;
drag = null;
root.querySelectorAll('[data-over="1"]').forEach(function (el) { delete el.dataset.over; });
delete root.dataset.dragging;
if (d.ghost) d.ghost.remove();
if (!d.live) return; // never crossed the threshold: it was a tap
swallowClick = true;
setTimeout(function () { swallowClick = false; }, 0); // in case no click follows
if (commit && d.target && held) {
var move = { kind: "move", from: d.pile, to: d.target, count: held.count };
drop();
send(move);
return;
}
// Dropped on nothing, or somewhere it doesn't go. The run goes back where it
// came from and stays in your hand, so the drop targets are still lit and a
// tap can finish what the drag started.
if (commit && d.targetEl) nope(d.targetEl);
}
root.querySelector(".pete-felt").addEventListener("pointerdown", function (e) {
if (busy || !board || board.phase !== "playing") return;
if (e.button !== 0 && e.pointerType === "mouse") return;
var cardEl = e.target.closest('.pete-card[data-live="1"]');
if (!cardEl) return;
var r = cardEl.getBoundingClientRect();
drag = {
pile: cardEl.dataset.pile,
idx: parseInt(cardEl.dataset.idx, 10),
x0: e.clientX,
y0: e.clientY,
dx: e.clientX - r.left,
dy: e.clientY - r.top,
width: r.width,
live: false,
target: null,
targetEl: null,
id: e.pointerId,
el: cardEl,
};
});
root.querySelector(".pete-felt").addEventListener("pointermove", function (e) {
if (!drag || e.pointerId !== drag.id) return;
if (!drag.live) {
if (Math.abs(e.clientX - drag.x0) < SLOP && Math.abs(e.clientY - drag.y0) < SLOP) return;
// It's a drag. Pick the run up — the same pick a tap would have made — and
// if it isn't liftable there's nothing to drag and this goes back to being
// an ordinary press.
pick(drag.pile, drag.idx);
if (!held || held.pile !== drag.pile) { drag = null; return; }
drag.live = true;
drag.ghost = ghostFor(held.cards, drag);
root.dataset.dragging = "1";
// Capture only now that it's a drag, so the pointer stays ours even off the
// felt. Doing it on the press instead would retarget the click that a *tap*
// ends with, and tapping would quietly stop working.
try { drag.el.setPointerCapture(e.pointerId); } catch (_) {}
}
e.preventDefault();
dragTo(e.clientX, e.clientY);
});
root.querySelector(".pete-felt").addEventListener("pointerup", function (e) {
if (drag && e.pointerId === drag.id) dragEnd(true);
});
root.querySelector(".pete-felt").addEventListener("pointercancel", function (e) {
if (drag && e.pointerId === drag.id) dragEnd(false);
});
// Double-click sends a card home. It's the idiom every solitaire has used for // 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 // thirty years, and the alternative is asking the player which foundation — a
// question with exactly one right answer. // question with exactly one right answer.
+1 -1
View File
@@ -96,7 +96,7 @@
</button> </button>
<p class="text-xs text-[color:var(--ink)]/45"> <p class="text-xs text-[color:var(--ink)]/45">
Click a card, then where it goes. Double-click sends it home. Drag a card where it goes, or click it and then click the spot. Double-click sends it home.
</p> </p>
<button type="button" data-cash <button type="button" data-cash