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:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user