Fix Texas Hold'em dealer page: script timing crash and seat overlap #11

Merged
jheaps merged 1 commits from feature/casino-texas-holdem into master 2026-08-31 15:03:37 -06:00
4 changed files with 315 additions and 189 deletions
+18 -4
View File
@@ -34,6 +34,13 @@
</section>
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<h2 id="seatEditorTitle">Select a seat</h2>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
</section>
<details class="sidebarPanel" open>
<summary>Table Setup</summary>
<div class="panelBody">
@@ -81,10 +88,18 @@
<button class="dealerChip" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<div class="seatCard">
<div class="seatCardTop">
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
</div>
</template>
<template id="seatEditorTemplate">
<div class="seatEditorForm">
<label class="fieldRow">
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
<div class="seatCardToggles">
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" />
@@ -110,7 +125,6 @@
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div>
</div>
</template>
<template id="chipLegendRowTemplate">
+83 -38
View File
@@ -117,7 +117,7 @@ body {
#pokerTableWrap {
position: relative;
width: 100%;
padding-bottom: 62%;
padding-bottom: 72%;
}
#pokerTable {
@@ -161,19 +161,21 @@ body {
inset: 0;
}
/* ---------- Seats ---------- */
/* ---------- Seats (compact pucks on the table) ---------- */
.seat {
position: absolute;
transform: translate(-50%, -50%);
width: 190px;
width: 96px;
min-height: 54px;
background: #232323;
border: 1px solid #3a3a3a;
border: 2px solid #3a3a3a;
border-radius: 10px;
padding: 0.6rem;
padding: 0.3rem 0.4rem;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
cursor: grab;
z-index: 1;
text-align: center;
}
.seat.dragging {
@@ -189,30 +191,60 @@ body {
opacity: 0.45;
}
.seat.seatFolded .seatCard {
opacity: 0.55;
.seat.seatFolded {
border-style: dashed;
opacity: 0.6;
}
.seat.seatSelected {
border-color: #09ff00;
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.35), 0 4px 10px rgba(0, 0, 0, 0.4);
}
.seatChairNumber {
position: absolute;
top: 2px;
left: 5px;
font-size: 0.6rem;
color: #777;
}
.seatPuckName {
font-size: 0.72rem;
font-weight: 600;
color: #eee;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.6rem;
}
.seatPuckCards {
font-size: 0.65rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.3rem;
font-family: var(--font-mono, monospace);
color: #bbb;
min-height: 0.9em;
}
.seatPuckTotal {
font-size: 0.7rem;
font-weight: 700;
color: #9be6a8;
min-height: 1em;
}
.dealerChip {
position: absolute;
top: -10px;
right: -10px;
width: 26px;
height: 26px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #444;
color: #ccc;
border: 2px solid #666;
font-weight: 700;
font-size: 0.75rem;
font-size: 0.68rem;
cursor: pointer;
z-index: 2;
}
@@ -226,10 +258,10 @@ body {
.blindBadge {
display: none;
position: absolute;
top: -10px;
font-size: 0.65rem;
top: -9px;
font-size: 0.58rem;
font-weight: 700;
padding: 0.1rem 0.4rem;
padding: 0.05rem 0.3rem;
border-radius: 8px;
z-index: 2;
}
@@ -245,15 +277,43 @@ body {
}
.bbBadge {
left: 26px;
left: 22px;
background: #b7791f;
color: #fff7e6;
}
.seatCardTop {
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
padding: 0.9rem;
}
.seatEditorPanel h2 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
font-weight: 500;
color: #f5f5f5;
}
.seatEditorForm {
display: flex;
gap: 0.4rem;
margin-bottom: 0.35rem;
flex-direction: column;
gap: 0.6rem;
}
.seatCardToggles {
display: flex;
gap: 0.6rem;
font-size: 0.8rem;
color: #aaa;
}
.inHandToggle,
.foldToggle {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.seatNameInput {
@@ -267,22 +327,6 @@ body {
font-size: 0.8rem;
}
.seatCardToggles {
display: flex;
gap: 0.6rem;
margin-bottom: 0.4rem;
font-size: 0.7rem;
color: #aaa;
}
.inHandToggle,
.foldToggle {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.seatCards {
display: flex;
gap: 0.35rem;
@@ -598,6 +642,7 @@ body {
}
.seat {
width: 150px;
width: 78px;
min-height: 44px;
}
}
@@ -1,4 +1,4 @@
(function () {
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
const hooks = {
@@ -67,4 +67,4 @@
document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close();
});
})();
});
+187 -120
View File
@@ -1,19 +1,30 @@
// All DOM rendering + drag/drop for the table. Talks to the outside world only through
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
// re-draws everything (used after structural changes like resizing seats or the chip
// legend), hooks.rerenderPotBadge(state) just updates the pot total display.
// legend).
//
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
// full form in the seat editor panel instead.
const Render = {
state: null,
hooks: null,
selectedSeatIndex: null,
mount(state, hooks) {
this.state = state;
this.hooks = hooks;
this.selectedSeatIndex = null;
this.renderAll();
},
renderAll() {
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
this.selectedSeatIndex = null;
}
this.renderSeats();
this.renderSeatEditor();
this.renderChipLegend();
this.renderNotes();
this.updatePotBadge();
@@ -36,29 +47,22 @@ const Render = {
document.getElementById("potBadgeAmount").textContent = Money.format(total);
},
commitAndRedrawSeat(seatEl, seatIndex) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(seatEl, this.state.seats[seatIndex]);
},
renderSeatTotal(seatEl, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
seatEl.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
// ---- Seats around the table ----
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count) {
const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180;
const rx = 44;
const ry = 40;
const ry = 42;
const left = 50 + rx * Math.cos(radians);
const top = 50 + ry * Math.sin(radians);
return { left, top };
},
puckLabel(player, seatIndex) {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
@@ -72,11 +76,12 @@ const Render = {
node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index);
node.querySelector(".seatChairNumber").textContent = `Seat ${index + 1}`;
node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.addEventListener("click", () => {
dealerChip.addEventListener("click", e => {
e.stopPropagation();
this.state.dealerSeatIndex = index;
this.hooks.save(this.state);
this.renderSeats();
@@ -85,12 +90,14 @@ const Render = {
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
this.wireSeatFields(node, player, index);
this.renderChipCounts(node, player);
this.renderCustomFields(node, player);
this.renderSeatTotal(node, player);
node.classList.toggle("seatInactive", !player.inHand);
node.classList.toggle("seatFolded", player.folded);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
node.addEventListener("click", () => {
this.selectedSeatIndex = index;
this.renderSeats();
this.renderSeatEditor();
});
this.wireDragAndDrop(node, index);
@@ -98,106 +105,21 @@ const Render = {
});
},
wireSeatFields(node, player, index) {
const nameInput = node.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
this.hooks.save(this.state);
});
const inHandCheckbox = node.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
this.renderSeats();
this.hooks.save(this.state);
this.updatePotBadge();
});
const foldCheckbox = node.querySelector(".foldCheckbox");
foldCheckbox.checked = player.folded;
foldCheckbox.disabled = !player.inHand;
foldCheckbox.addEventListener("change", () => {
player.folded = foldCheckbox.checked;
updatePuckContent(node, player, index) {
node.querySelector(".seatPuckName").textContent = player.name && player.name.trim() ? player.name.trim() : "";
const cards = (player.cards || []).filter(Boolean).join(" ");
node.querySelector(".seatPuckCards").textContent = cards;
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
node.classList.toggle("seatInactive", !player.inHand);
node.classList.toggle("seatFolded", player.folded);
this.hooks.save(this.state);
});
const card1 = node.querySelector(".card1Input");
const card2 = node.querySelector(".card2Input");
card1.value = player.cards[0] || "";
card2.value = player.cards[1] || "";
[card1, card2].forEach((input, cardIdx) => {
input.addEventListener("input", () => {
input.value = input.value.toUpperCase();
player.cards[cardIdx] = input.value;
this.hooks.save(this.state);
});
});
const amountInput = node.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitAndRedrawSeat(node, index);
});
const addFieldBtn = node.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(node, player);
this.hooks.save(this.state);
});
},
renderChipCounts(node, player) {
const container = node.querySelector(".chipCounts");
container.innerHTML = "";
const template = document.getElementById("chipCountTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
row.title = `$${chip.value} chips`;
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitAndRedrawSeat(node, Number(node.dataset.seatIndex));
});
container.appendChild(row);
});
},
renderCustomFields(node, player) {
const container = node.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(node, player);
this.hooks.save(this.state);
});
container.appendChild(row);
});
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (node) this.updatePuckContent(node, this.state.seats[index], index);
},
wireDragAndDrop(node, index) {
@@ -222,6 +144,151 @@ const Render = {
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
this.hooks.save(this.state);
this.renderSeats();
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
this.renderSeatEditor();
}
});
},
// ---- Seat editor panel (the full form for whichever seat is selected) ----
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
if (this.selectedSeatIndex === null) {
title.textContent = "Select a seat";
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
return;
}
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
title.textContent = this.puckLabel(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
const form = template.content.firstElementChild.cloneNode(true);
this.wireSeatFields(form, player, index);
this.renderChipCounts(form, player, index);
this.renderCustomFields(form, player, index);
this.renderSeatTotal(form, player);
body.appendChild(form);
},
renderSeatTotal(form, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
commitSeatChange(form, player, index) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
this.commitSeatChange(form, player, index);
});
const card1 = form.querySelector(".card1Input");
const card2 = form.querySelector(".card2Input");
card1.value = player.cards[0] || "";
card2.value = player.cards[1] || "";
[card1, card2].forEach((input, cardIdx) => {
input.addEventListener("input", () => {
input.value = input.value.toUpperCase();
player.cards[cardIdx] = input.value;
this.commitSeatChange(form, player, index);
});
});
const amountInput = form.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitSeatChange(form, player, index);
});
const addFieldBtn = form.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
},
renderChipCounts(form, player, index) {
const container = form.querySelector(".chipCounts");
container.innerHTML = "";
const template = document.getElementById("chipCountTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
row.title = `$${chip.value} chips`;
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitSeatChange(form, player, index);
});
container.appendChild(row);
});
},
renderCustomFields(form, player, index) {
const container = form.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
container.appendChild(row);
});
},
@@ -242,12 +309,12 @@ const Render = {
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
this.renderSeats();
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
this.updatePotBadge();
});
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {