@@ -279,6 +291,7 @@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index abe0eb7..8a91e49 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -238,7 +238,12 @@ body {
cursor: grab;
z-index: 1;
text-align: center;
- transition: box-shadow 0.15s ease, border-color 0.15s ease;
+ transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
+ /* A long press on a phone otherwise selects the player's name or raises the
+ system callout menu instead of ours. */
+ user-select: none;
+ -webkit-user-select: none;
+ -webkit-touch-callout: none;
}
.seat.dragging {
@@ -518,6 +523,99 @@ body {
font-weight: 400;
}
+/* ---------- Seat long-press menu ---------- */
+
+/* Held down, before the menu opens — the only feedback that a press is being
+ counted rather than ignored. */
+.seat.seatPressing {
+ transform: translate(-50%, -50%) scale(0.94);
+}
+
+#seatMenuOverlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.72);
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ z-index: 96;
+}
+
+#seatMenuOverlay[hidden] {
+ display: none;
+}
+
+#seatMenuSheet {
+ width: 100%;
+ max-width: 420px;
+ background: linear-gradient(180deg, #242424, #1a1a1a);
+ border: 1px solid #3a3a3a;
+ border-bottom: none;
+ border-radius: 16px 16px 0 0;
+ padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
+ box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
+}
+
+.seatMenuHeader {
+ margin-bottom: 0.8rem;
+}
+
+.seatMenuHeader h2 {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 500;
+ color: #f5f5f5;
+}
+
+#seatMenuSubtitle {
+ color: #9a9a9a;
+ font-size: 0.8rem;
+ font-variant-numeric: tabular-nums;
+}
+
+#seatMenuActions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ margin-bottom: 0.6rem;
+}
+
+.seatMenuBtn {
+ width: 100%;
+ min-height: 52px;
+ border-radius: 11px;
+ border: 1px solid #3a3a3a;
+ background: linear-gradient(180deg, #2e2e2e, #232323);
+ color: #eee;
+ font-family: inherit;
+ font-size: 0.95rem;
+ cursor: pointer;
+ transition: all 0.12s ease;
+}
+
+.seatMenuBtn:hover {
+ border-color: #4a4a4a;
+ background: linear-gradient(180deg, #383838, #2a2a2a);
+}
+
+.seatMenuBtn.menuDanger {
+ background: linear-gradient(180deg, #3d1f1f, #2c1515);
+ border-color: #5b2626;
+ color: #ffd6d6;
+}
+
+.seatMenuBtn.menuAccent {
+ background: linear-gradient(180deg, #3a2f18, #241d0f);
+ border-color: #a9782f;
+ color: #f0d9a8;
+}
+
+#seatMenuCancelBtn {
+ width: 100%;
+ justify-content: center;
+ min-height: 48px;
+}
+
/* ---------- Card picker ---------- */
#cardPickerOverlay {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 7c18086..0c547f9 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -1,9 +1,62 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
+ // A gesture on a seat is a shortcut for something the dealer could do the long
+ // way, so each one is undoable the same way the header buttons are.
+ const seatAction = (index, message, mutate) => {
+ const snapshot = JSON.parse(JSON.stringify(state));
+ mutate(state.seats[index], index);
+ HoldemState.save(state);
+ Render.renderAll();
+
+ Toast.show(message, {
+ actionLabel: "Undo",
+ onAction: () => {
+ Object.keys(state).forEach(key => delete state[key]);
+ Object.assign(state, snapshot);
+ HoldemState.save(state);
+ Render.renderAll();
+ Toast.show("Undone.", { duration: 2500 });
+ },
+ duration: 6000,
+ });
+ };
+
+ const nameOf = index => HoldemState.displayName(state.seats[index], index);
+
const hooks = {
save: s => HoldemState.save(s),
rerender: () => Render.renderAll(),
+
+ onSeatDoubleTap: index => {
+ const player = state.seats[index];
+ if (!player.inHand) return;
+ if (player.folded) {
+ seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; });
+ return;
+ }
+ seatAction(index, `${nameOf(index)} folds.`, () => {
+ if (state.actingSeatIndex === index) Betting.fold(state, index);
+ else player.folded = true;
+ });
+ },
+
+ onSeatLongPress: index => SeatMenu.open(state, seatMenuHooks, index),
+ };
+
+ const seatMenuHooks = {
+ fold: index => hooks.onSeatDoubleTap(index),
+ unfold: index => seatAction(index, `${nameOf(index)} is back in the hand.`, p => { p.folded = false; }),
+ allIn: index => seatAction(index, `${nameOf(index)} is all-in.`, () => Betting.allIn(state, index)),
+ toggleDealtIn: index => {
+ const dealtIn = state.seats[index].inHand;
+ seatAction(index, dealtIn ? `${nameOf(index)} sits out.` : `${nameOf(index)} is dealt in.`, p => {
+ p.inHand = !dealtIn;
+ if (!p.inHand) p.folded = false;
+ });
+ },
+ makeDealer: index => seatAction(index, `Button on ${nameOf(index)}.`, () => { state.dealerSeatIndex = index; }),
+ editSeat: index => Render.toggleSeatEditor(index),
};
Render.mount(state, hooks);
@@ -144,6 +197,11 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
+ document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
+ document.getElementById("seatMenuOverlay").addEventListener("click", e => {
+ if (e.target.id === "seatMenuOverlay") SeatMenu.close();
+ });
+
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index e8eaf68..416e688 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -47,9 +47,9 @@ const Render = {
renderTableHint() {
const hint = document.getElementById("tableHint");
- hint.textContent = this.state.dedicatedDealer
- ? "Drag a seat onto another seat to swap players."
- : "Drag a seat onto another seat to swap players. Click the dealer chip on a seat to make it the button.";
+ // Gestures are invisible until someone tells you about them, and this is the
+ // only place on the table with room to say so.
+ hint.textContent = "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats.";
},
renderNotes() {
@@ -113,15 +113,7 @@ const Render = {
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
- node.addEventListener("click", () => {
- // Tapping the open seat again closes it — on a phone the editor is a
- // sheet over the table, and the seat you just tapped is the closest
- // thing to hand for dismissing it.
- this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
- this.renderSeats();
- this.renderSeatEditor();
- });
-
+ this.wireSeatGestures(node, index);
this.wireDragAndDrop(node, index);
layer.appendChild(node);
@@ -202,6 +194,109 @@ const Render = {
if (node) this.updatePuckContent(node, this.state.seats[index], index);
},
+ // ---- Seat gestures ----
+ //
+ // One puck, three intents: tap to edit, double-tap to fold, press and hold for
+ // the quick menu. The single tap waits out the double-tap window before it
+ // fires — a fifth of a second of latency on opening a form, in exchange for a
+ // fold that doesn't also fling a sheet up over the table.
+
+ LONG_PRESS_MS: 500,
+ DOUBLE_TAP_MS: 220,
+ DRAG_SLOP_PX: 8,
+ pendingTap: null,
+
+ wireSeatGestures(node, index) {
+ let longPressTimer = null;
+ let longPressFired = false;
+ let startX = 0;
+ let startY = 0;
+
+ const cancelLongPress = () => {
+ if (longPressTimer) {
+ clearTimeout(longPressTimer);
+ longPressTimer = null;
+ }
+ };
+
+ node.addEventListener("pointerdown", e => {
+ longPressFired = false;
+ startX = e.clientX;
+ startY = e.clientY;
+ cancelLongPress();
+ longPressTimer = setTimeout(() => {
+ longPressTimer = null;
+ longPressFired = true;
+ this.cancelPendingTap();
+ node.classList.remove("seatPressing");
+ this.hooks.onSeatLongPress(index);
+ }, this.LONG_PRESS_MS);
+ node.classList.add("seatPressing");
+ });
+
+ // Moving means they're dragging the seat, not holding it.
+ node.addEventListener("pointermove", e => {
+ if (Math.abs(e.clientX - startX) > this.DRAG_SLOP_PX || Math.abs(e.clientY - startY) > this.DRAG_SLOP_PX) {
+ cancelLongPress();
+ node.classList.remove("seatPressing");
+ }
+ });
+
+ ["pointerup", "pointercancel", "pointerleave"].forEach(type => {
+ node.addEventListener(type, () => {
+ cancelLongPress();
+ node.classList.remove("seatPressing");
+ });
+ });
+ node.addEventListener("dragstart", () => {
+ cancelLongPress();
+ this.cancelPendingTap();
+ node.classList.remove("seatPressing");
+ });
+
+ node.addEventListener("click", e => {
+ // The long press already did something; don't also treat the release as a tap.
+ if (longPressFired) {
+ longPressFired = false;
+ e.preventDefault();
+ e.stopPropagation();
+ return;
+ }
+ this.handleSeatTap(index);
+ });
+ },
+
+ cancelPendingTap() {
+ if (this.pendingTap) {
+ clearTimeout(this.pendingTap.timer);
+ this.pendingTap = null;
+ }
+ },
+
+ handleSeatTap(index) {
+ if (this.pendingTap && this.pendingTap.index === index) {
+ this.cancelPendingTap();
+ this.hooks.onSeatDoubleTap(index);
+ return;
+ }
+
+ this.cancelPendingTap();
+ const timer = setTimeout(() => {
+ this.pendingTap = null;
+ this.toggleSeatEditor(index);
+ }, this.DOUBLE_TAP_MS);
+ this.pendingTap = { index, timer };
+ },
+
+ // Tapping the open seat again closes it — on a phone the editor is a sheet over
+ // the table, and the seat you just tapped is the closest thing to hand for
+ // dismissing it.
+ toggleSeatEditor(index) {
+ this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
+ this.renderSeats();
+ this.renderSeatEditor();
+ },
+
wireDragAndDrop(node, index) {
node.addEventListener("dragstart", e => {
e.dataTransfer.setData("text/plain", String(index));
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
new file mode 100644
index 0000000..ba38380
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
@@ -0,0 +1,77 @@
+// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
+// one press and one tap away, without opening the full editor and without hunting
+// for whose turn it is in the action bar.
+const SeatMenu = {
+ state: null,
+ hooks: null,
+ seatIndex: null,
+
+ open(state, hooks, seatIndex) {
+ this.state = state;
+ this.hooks = hooks;
+ this.seatIndex = seatIndex;
+ this.render();
+ document.getElementById("seatMenuOverlay").hidden = false;
+ },
+
+ close() {
+ document.getElementById("seatMenuOverlay").hidden = true;
+ },
+
+ // Only the actions that make sense for this seat right now — a folded player has
+ // nothing to go all-in with, and an empty chair has nothing to fold.
+ actionsFor(player, index) {
+ const actions = [];
+ const dealtIn = player.inHand;
+ const live = dealtIn && !player.folded;
+
+ if (live) {
+ actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
+ actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
+ } else if (dealtIn && player.folded) {
+ actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
+ }
+
+ actions.push({
+ label: dealtIn ? "Sit out" : "Deal in",
+ run: () => this.hooks.toggleDealtIn(index),
+ });
+
+ if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
+ actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
+ }
+
+ actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
+ return actions;
+ },
+
+ render() {
+ const player = this.state.seats[this.seatIndex];
+ document.getElementById("seatMenuTitle").textContent = HoldemState.displayName(player, this.seatIndex);
+
+ const stack = Betting.stackOf(this.state, player);
+ const owed = Betting.toCall(this.state, player);
+ const bits = [];
+ if (stack > 0) bits.push(`${Money.format(stack)} behind`);
+ if (owed > 0) bits.push(`${Money.format(owed)} to call`);
+ document.getElementById("seatMenuSubtitle").textContent = bits.join(" · ");
+
+ const list = document.getElementById("seatMenuActions");
+ list.innerHTML = "";
+ this.actionsFor(player, this.seatIndex).forEach(action => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = `seatMenuBtn ${action.className || ""}`;
+ btn.textContent = action.label;
+ btn.addEventListener("click", () => {
+ this.close();
+ action.run();
+ });
+ list.appendChild(btn);
+ });
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = SeatMenu;
+}