Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ee2fb2b7 | ||
|
|
ae927983e7 | ||
|
|
4ceeed71a2 |
@@ -31,6 +31,7 @@
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="betsLayer" aria-hidden="true"></div>
|
||||
<div id="seatsLayer"></div>
|
||||
</div>
|
||||
<p id="tableHint" class="tableHint"></p>
|
||||
@@ -81,34 +82,25 @@
|
||||
<span>Starting stack ($)</span>
|
||||
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
|
||||
</label>
|
||||
<p class="fieldHint">Blinds post themselves each hand, and every seat without its own stack starts here. Set a seat's own stack in its editor.</p>
|
||||
<label class="fieldRow">
|
||||
<span>Base pot amount ($)</span>
|
||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||
</label>
|
||||
<p class="fieldHint">Every player who didn't go all-in short is assumed to have put in this amount. Enter a smaller amount on a seat only if that player is all-in for less.</p>
|
||||
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
|
||||
|
||||
<details class="subPanel">
|
||||
<summary>Without live betting</summary>
|
||||
<div class="subPanelBody">
|
||||
<label class="fieldRow">
|
||||
<span>Base pot amount ($)</span>
|
||||
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
|
||||
</label>
|
||||
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Chip Legend</summary>
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
<p class="fieldHint">Give each chip color a dollar value, then count chips per seat instead of typing a total.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Session Standings</summary>
|
||||
<summary>Session</summary>
|
||||
<div class="panelBody">
|
||||
<h3 class="panelSubhead">Standings</h3>
|
||||
<div id="standingsTable">
|
||||
<div class="standingsRow standingsHeader">
|
||||
<span>Player</span>
|
||||
@@ -119,20 +111,33 @@
|
||||
<div id="standingsList"></div>
|
||||
</div>
|
||||
<p id="standingsEmptyHint" class="fieldHint">Once you resolve a pot, everyone's running total for the night shows up here.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Hand History</summary>
|
||||
<div class="panelBody">
|
||||
<h3 class="panelSubhead">Hands</h3>
|
||||
<div id="handHistoryList"></div>
|
||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots show up here as you play, in case a chip count is disputed later.</p>
|
||||
<p id="handHistoryEmptyHint" class="fieldHint">Resolved pots are logged here, in case a chip count is disputed later.</p>
|
||||
|
||||
<div class="handHistoryActions">
|
||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Chip Legend</summary>
|
||||
<div class="panelBody">
|
||||
<div id="chipLegendList"></div>
|
||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,6 +299,7 @@
|
||||
<script src="~/js/casino/holdem/seatMenu.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||
|
||||
@@ -223,6 +223,68 @@ body {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* ---------- Bets on the felt ---------- */
|
||||
|
||||
/* Shares the seat ring's box exactly, so a bet always lands on the spoke between its
|
||||
player and the pot. When the seat ring gets inset on a phone, this moves with it —
|
||||
measured the alternative, and a bets ring on its own geometry ends up either behind
|
||||
the pucks or on top of the pot badge. */
|
||||
#betsLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.betChip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
padding: 0.1rem 0.42rem 0.1rem 0.2rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Reads as a chip seen from above: a lighter top-left, a rim, and the notches a
|
||||
casino chip has around its edge. */
|
||||
.betChipDisc {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
box-shadow:
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.45),
|
||||
inset 0 -1px 2px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.betChipAmount {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: #f0f0f0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Clinging to a puck rather than sitting on its own ring — the phone layout. */
|
||||
.betChipAnchored {
|
||||
left: 50%;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.betChipAbove {
|
||||
top: -11px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.betChipBelow {
|
||||
top: auto;
|
||||
bottom: -11px;
|
||||
transform: translate(-50%, 50%);
|
||||
}
|
||||
|
||||
/* ---------- Seats (compact pucks on the table) ---------- */
|
||||
|
||||
.seat {
|
||||
@@ -872,6 +934,46 @@ body {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
/* A heading inside a panel, so one panel can hold two related things — the
|
||||
standings and the hand log are two views of the same session — without needing
|
||||
two identical-looking accordions to say so. */
|
||||
.panelSubhead {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #d8b46a;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panelSubhead + * {
|
||||
margin-top: -0.2rem;
|
||||
}
|
||||
|
||||
/* Nested disclosure for the things a dealer sets once and then forgets, kept out
|
||||
of the way of the settings they actually touch. */
|
||||
.subPanel {
|
||||
border-top: 1px solid #2c2c2c;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.subPanel summary {
|
||||
cursor: pointer;
|
||||
color: #8f8f8f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.subPanel summary:hover {
|
||||
color: #c5c5c5;
|
||||
}
|
||||
|
||||
.subPanelBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1219,6 +1321,7 @@ body.hasActionBar #holdemSidebar {
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1256,6 +1359,29 @@ body.hasActionBar #holdemSidebar {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.actionKey {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 7px;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid currentColor;
|
||||
opacity: 0.4;
|
||||
font-family: inherit;
|
||||
font-size: 0.6rem;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* No keyboard, no key badges — on a phone they'd just be clutter on the buttons
|
||||
the dealer is already tapping. */
|
||||
@media (hover: none) {
|
||||
.actionKey {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.actionFold {
|
||||
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||
border-color: #5b2626;
|
||||
@@ -1366,11 +1492,18 @@ body.hasActionBar #holdemSidebar {
|
||||
.blindsRow .fieldRow {
|
||||
flex: 1 1 120px;
|
||||
min-width: 0;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* "Small blind" broke onto two lines beside its input, which made the pair look
|
||||
like a mistake rather than a pair. */
|
||||
.blindsRow .fieldRow > span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.blindsRow input {
|
||||
min-width: 0;
|
||||
width: 5rem;
|
||||
width: 3.4rem;
|
||||
}
|
||||
|
||||
/* ---------- Toasts ---------- */
|
||||
@@ -1663,7 +1796,8 @@ body.hasActionBar #toastHost {
|
||||
|
||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||
so half a puck of padding is exactly enough to stop the overhang. */
|
||||
#seatsLayer {
|
||||
#seatsLayer,
|
||||
#betsLayer {
|
||||
inset: 26px 44px;
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,19 @@ const ActionBar = {
|
||||
);
|
||||
},
|
||||
|
||||
// Which key presses each button. Shown on the button itself and hidden on touch
|
||||
// devices — a shortcut nobody can discover may as well not exist, and a key badge
|
||||
// on a phone is just clutter.
|
||||
KEY_HINTS: {
|
||||
actionFold: "F",
|
||||
actionCheck: "C",
|
||||
actionCall: "C",
|
||||
actionRaise: "R",
|
||||
actionAdvance: "↵",
|
||||
raiseConfirm: "↵",
|
||||
raiseCancel: "esc",
|
||||
},
|
||||
|
||||
button(label, className, onClick, sublabel) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
@@ -204,6 +217,14 @@ const ActionBar = {
|
||||
main.textContent = label;
|
||||
btn.appendChild(main);
|
||||
|
||||
const hint = this.KEY_HINTS[className];
|
||||
if (hint) {
|
||||
const kbd = document.createElement("kbd");
|
||||
kbd.className = "actionKey";
|
||||
kbd.textContent = hint;
|
||||
btn.appendChild(kbd);
|
||||
}
|
||||
|
||||
if (sublabel) {
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "actionBtnSub";
|
||||
|
||||
@@ -116,6 +116,38 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// snapshots refer to a hand that no longer exists.
|
||||
const clearActionHistory = () => { actionHistory.length = 0; };
|
||||
|
||||
// Keyboard shortcuts press the very same buttons a dealer would tap, rather than
|
||||
// calling the betting engine themselves — there's no second code path to drift out
|
||||
// of step with what the bar is actually offering right now.
|
||||
const pressInBar = selector => {
|
||||
const btn = document.querySelector(`#actionBar ${selector}`);
|
||||
if (!btn || btn.disabled) return false;
|
||||
btn.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
const quickRaiseButton = label =>
|
||||
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
|
||||
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
|
||||
|
||||
Shortcuts.mount({
|
||||
fold: () => pressInBar(".actionFold"),
|
||||
checkOrCall: () => pressInBar(".actionCheck") || pressInBar(".actionCall"),
|
||||
raise: () => pressInBar(".actionRaise"),
|
||||
allIn: () => {
|
||||
// Open the raise pad first if it isn't already, then take its All-in amount.
|
||||
if (!document.querySelector("#actionBar .raisePad") && !pressInBar(".actionRaise")) return false;
|
||||
const quick = quickRaiseButton("All-in");
|
||||
if (!quick) return false;
|
||||
quick.click();
|
||||
return true;
|
||||
},
|
||||
// Enter confirms a raise if one is being entered, otherwise moves the hand on.
|
||||
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance"),
|
||||
undo: () => pressInBar(".actionUndo"),
|
||||
cancel: () => pressInBar(".raiseCancel"),
|
||||
});
|
||||
|
||||
// Moving to the next hand used to ask "are you sure?" every single time —
|
||||
// the most-pressed button in a session, gated behind a system dialog. It now
|
||||
// just happens, and the toast offers the way back for the few seconds anyone
|
||||
|
||||
@@ -69,13 +69,85 @@ const Render = {
|
||||
// ---- Seats around the table (compact pucks) ----
|
||||
|
||||
seatPosition(index, count) {
|
||||
return this.positionOnRing(index, count, 44, 42);
|
||||
},
|
||||
|
||||
// Bets sit on the felt between their player and the pot, the way chips actually
|
||||
// do — closer in than the seats, on the same spoke. The bets layer is inset to
|
||||
// match the seat ring (see the CSS), so this one radius holds at every size.
|
||||
betPosition(index, count) {
|
||||
return this.positionOnRing(index, count, 27, 25);
|
||||
},
|
||||
|
||||
positionOnRing(index, count, rx, ry) {
|
||||
const angle = -90 + (360 / count) * index;
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const rx = 44;
|
||||
const ry = 42;
|
||||
const left = 50 + rx * Math.cos(radians);
|
||||
const top = 50 + ry * Math.sin(radians);
|
||||
return { left, top };
|
||||
return {
|
||||
left: 50 + rx * Math.cos(radians),
|
||||
top: 50 + ry * Math.sin(radians),
|
||||
};
|
||||
},
|
||||
|
||||
// Colour a bet by the biggest chip that fits it, so a table with a chip legend
|
||||
// gets bets that look like the chips actually being pushed out.
|
||||
chipColorFor(amount) {
|
||||
const legend = [...(this.state.chipLegend || [])]
|
||||
.filter(chip => Number(chip.value) > 0)
|
||||
.sort((a, b) => Number(b.value) - Number(a.value));
|
||||
const match = legend.find(chip => amount >= Number(chip.value));
|
||||
return (match || legend[legend.length - 1] || {}).color || "#8a8a8a";
|
||||
},
|
||||
|
||||
// ---- Bets on the felt ----
|
||||
|
||||
makeBetChip(bet) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "betChip";
|
||||
|
||||
const disc = document.createElement("span");
|
||||
disc.className = "betChipDisc";
|
||||
disc.style.background = this.chipColorFor(bet);
|
||||
|
||||
const amount = document.createElement("span");
|
||||
amount.className = "betChipAmount";
|
||||
amount.textContent = Money.format(bet);
|
||||
|
||||
chip.appendChild(disc);
|
||||
chip.appendChild(amount);
|
||||
return chip;
|
||||
},
|
||||
|
||||
// A phone's felt has no room for a third ring: measured every radius between the
|
||||
// seats and the pot badge and each one collides with one or the other. So on a
|
||||
// narrow screen the bet clings to its own puck instead — same chip, no ring —
|
||||
// sitting on whichever side faces the middle of the table.
|
||||
renderBets() {
|
||||
const layer = document.getElementById("betsLayer");
|
||||
if (!layer) return;
|
||||
layer.innerHTML = "";
|
||||
|
||||
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
||||
|
||||
this.state.seats.forEach((player, index) => {
|
||||
const bet = Betting.committed(player);
|
||||
if (bet <= 0) return;
|
||||
|
||||
const chip = this.makeBetChip(bet);
|
||||
const pos = this.betPosition(index, this.state.seatCount);
|
||||
|
||||
if (anchorToSeat) {
|
||||
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||
if (!seat) return;
|
||||
const seatPos = this.seatPosition(index, this.state.seatCount);
|
||||
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||||
seat.appendChild(chip);
|
||||
return;
|
||||
}
|
||||
|
||||
chip.style.left = `${pos.left}%`;
|
||||
chip.style.top = `${pos.top}%`;
|
||||
layer.appendChild(chip);
|
||||
});
|
||||
},
|
||||
|
||||
puckLabel(player, seatIndex) {
|
||||
@@ -118,6 +190,8 @@ const Render = {
|
||||
|
||||
layer.appendChild(node);
|
||||
});
|
||||
|
||||
this.renderBets();
|
||||
},
|
||||
|
||||
isRedSuit(card) {
|
||||
@@ -168,11 +242,13 @@ const Render = {
|
||||
span.textContent = this.formatCard(card);
|
||||
cardsEl.appendChild(span);
|
||||
});
|
||||
// What's in front of them this street reads as the live number during betting;
|
||||
// between hands it falls back to their total for the hand.
|
||||
const streetBet = Betting.committed(player);
|
||||
const total = streetBet > 0
|
||||
? streetBet
|
||||
// Always this player's total for the hand. What they've just pushed out on this
|
||||
// street is shown as a chip on the felt instead — the puck used to flip between
|
||||
// the two meanings depending on the moment, which is a bad thing for a number
|
||||
// sitting next to a stack size to do.
|
||||
const handTotal = Betting.handTotal(player);
|
||||
const total = handTotal > 0
|
||||
? handTotal
|
||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Keyboard control for a dealer running this on a laptop.
|
||||
//
|
||||
// The phone has gestures; the desktop had a mouse and nothing else. These are the
|
||||
// same actions the bar already offers, reachable without moving a hand to the
|
||||
// trackpad between every decision — which is the whole point when the bar is being
|
||||
// tapped thirty times a hand.
|
||||
const Shortcuts = {
|
||||
// Deliberately single letters with no modifier: a dealer's spare hand rests on
|
||||
// the keyboard, and a chord is slower than reaching for the button would be.
|
||||
KEYS: {
|
||||
f: "fold",
|
||||
c: "checkOrCall",
|
||||
r: "raise",
|
||||
a: "allIn",
|
||||
" ": "advance",
|
||||
enter: "advance",
|
||||
u: "undo",
|
||||
escape: "cancel",
|
||||
},
|
||||
|
||||
mount(handlers) {
|
||||
this.handlers = handlers;
|
||||
document.addEventListener("keydown", e => this.onKey(e));
|
||||
},
|
||||
|
||||
// Nothing fires while the dealer is typing a name, an amount, or a note — the
|
||||
// "c" in "Cass" must not fold the table.
|
||||
isTyping() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
},
|
||||
|
||||
// An overlay owns the keyboard while it's up: the confirm dialog already binds
|
||||
// Enter and Escape, and the card picker and seat menu are modal.
|
||||
overlayOpen() {
|
||||
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay"]
|
||||
.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return el && !el.hidden;
|
||||
});
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
|
||||
const action = this.KEYS[key];
|
||||
if (!action) return;
|
||||
|
||||
// Escape is the exception: it should still close the raise pad even if focus
|
||||
// happens to be sitting in its amount field.
|
||||
if (this.isTyping() && !(action === "cancel")) return;
|
||||
if (this.overlayOpen()) return;
|
||||
|
||||
const handled = this.handlers[action] && this.handlers[action]();
|
||||
if (handled) e.preventDefault();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Shortcuts;
|
||||
}
|
||||
Reference in New Issue
Block a user