diff --git a/public/src/games/holdem/HoldemAI.js b/public/src/games/holdem/HoldemAI.js index 166c2c7..925fe71 100644 --- a/public/src/games/holdem/HoldemAI.js +++ b/public/src/games/holdem/HoldemAI.js @@ -1,10 +1,14 @@ import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js'; -// Per-seat personalities indexed by seat number (1–3 for AI seats) +// Per-seat personalities indexed by seat number (1–7 for AI seats) const PERSONALITIES = { - 1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 }, // Marcus - 2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 }, // Sofia - 3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 }, // third AI + 1: { style: 'aggressive', raiseFactor: 0.55, bluffRate: 0.22 }, + 2: { style: 'tight', raiseFactor: 0.28, bluffRate: 0.10 }, + 3: { style: 'loose', raiseFactor: 0.44, bluffRate: 0.27 }, + 4: { style: 'aggressive', raiseFactor: 0.62, bluffRate: 0.30 }, + 5: { style: 'tight', raiseFactor: 0.22, bluffRate: 0.06 }, + 6: { style: 'loose', raiseFactor: 0.50, bluffRate: 0.18 }, + 7: { style: 'aggressive', raiseFactor: 0.48, bluffRate: 0.14 }, }; // Chen formula approximation: 0–20 score for pre-flop hand strength @@ -65,7 +69,11 @@ export function chooseAction(state, seat) { if (Math.random() < pers.bluffRate) strength = Math.min(1, strength + 0.40); // Fold threshold: fold if strength is below the price of calling - const foldThreshold = odds * 0.88; + let foldThreshold = odds * 0.88; + // When the pot hasn't been raised pre-flop it only costs the big blind to see + // the flop, so opponents limp in with almost anything instead of folding. + const unraisedPreflop = state.phase === 'preflop' && state.roundBet <= (state.blind?.big ?? state.roundBet); + if (unraisedPreflop) foldThreshold *= 0.25; if (isCheck) { // Check or bet diff --git a/public/src/games/holdem/HoldemGame.js b/public/src/games/holdem/HoldemGame.js index a82d34b..ea881a2 100644 --- a/public/src/games/holdem/HoldemGame.js +++ b/public/src/games/holdem/HoldemGame.js @@ -21,14 +21,13 @@ const CARD_W = 100; const CARD_H = 140; const CARD_R = 8; // corner radius -// Seat screen positions: [human, left-AI, top-AI, right-AI] -// portraitX/portraitY override the default world position when set. -const SEAT_POS = [ - { x: CX, y: 900, portraitR: 85, portraitX: CX - 210, portraitY: 900 }, // 0 — Human: portrait left of cards - { x: 220, y: 470, portraitR: 78 }, // 1 — AI left - { x: CX, y: 140, portraitR: 70, portraitX: CX - 175, portraitY: 140 }, // 2 — AI top: portrait left of cards - { x: 1700, y: 470, portraitR: 78 }, // 3 — AI right -]; +// Opponents are spaced evenly around a seating ellipse so the table stays +// balanced for any count (3–7 opponents). The human owns the bottom of the ring +// (fixed bottom-centre). Per-game positions are computed in computeSeatLayout(). +const SEAT_ELLIPSE = { cx: CX, cy: 540, rx: 760, ry: 330 }; +// Diagonal corner seats (top/bottom on either side) are pulled this far toward +// the table centre so they don't hug the screen edges. +const CORNER_NUDGE = 60; // Depth layers const D = { bg: -1, table: 0, cards: 10, chips: 20, ui: 30, modal: 50 }; @@ -175,31 +174,107 @@ export default class HoldemGame extends Phaser.Scene { // ── Seat containers ───────────────────────────────────────────────────────── + // Build per-game seat positions: human bottom-centre, opponents fanned evenly + // across the top arc of the seating ellipse (matches the legacy left/top/right + // look at 3 opponents, fans out to 7). + computeSeatLayout() { + const numOpp = Math.min(this.opponents.length, 7); + const portraitR = numOpp >= 5 ? 58 : 72; + const { cx, cy, rx, ry } = SEAT_ELLIPSE; + const seats = [ + { x: CX, y: 900, portraitR: 85, portraitX: CX - 210, portraitY: 900 }, // human + ]; + // Spread opponents evenly around the whole ellipse. The human owns the + // bottom slot (270°); opponents fill the remaining ring symmetrically. + const cardHalfSpan = (CARD_W * 0.7 + 8) / 2 + CARD_W / 2; // half-width of an opponent's card pair + const n = numOpp + 1; + for (let i = 1; i <= numOpp; i++) { + const deg = 270 - (i * 360) / n; + const rad = (deg * Math.PI) / 180; + const ex = cx + rx * Math.cos(rad); // outer (on-ellipse) anchor + const ey = cy - ry * Math.sin(rad); + + // Seats near the horizontal axis are "side" seats: cards pulled toward the + // table interior, portrait beside them on the outer edge (vertically + // centred), name/chips stacked below the portrait. + if (Math.abs(ey - CY) <= 90) { + const interiorSign = ex < CX ? 1 : -1; + const cardX = ex + interiorSign * (portraitR + 14 + cardHalfSpan); + seats.push({ + x: cardX, y: ey, portraitR, + portraitX: ex, portraitY: ey, + side: ex < CX ? 'left' : 'right', interiorSign, + }); + continue; + } + + // Pull diagonal corner seats inward (seats on the vertical centre axis stay put). + const ax = ex + (Math.abs(ex - cx) > 1 ? (ex < cx ? CORNER_NUDGE : -CORNER_NUDGE) : 0); + + const flipped = ey > CY; // bottom-of-table seats: portrait below, cards toward centre + let portraitX, portraitY; + if (flipped) { + portraitX = ax; + portraitY = ey + CARD_H / 2 + portraitR + 8; + } else { + // Portrait sits above the cards; if that clips the top edge, place it + // beside the cards (toward the nearest screen edge) at card height. + const aboveY = ey - CARD_H / 2 - portraitR - 8; + if (aboveY >= portraitR + 4) { + portraitX = ax; + portraitY = aboveY; + } else { + const spacing = CARD_W * 0.7 + 8; + const sideSign = ax <= CX ? -1 : 1; + portraitX = ax + sideSign * (spacing / 2 + portraitR + 12); + portraitY = ey; + } + } + seats.push({ x: ax, y: ey, portraitR, portraitX, portraitY, flipped }); + } + this.seatPos = seats; + } + buildSeatContainers() { - for (let seat = 0; seat < 4; seat++) { - const { x, y } = SEAT_POS[seat]; + this.computeSeatLayout(); + for (let seat = 0; seat < this.seatPos.length; seat++) { + const sp = this.seatPos[seat]; + const { x, y, flipped, side } = sp; const isHuman = seat === 0; const container = this.add.container(x, y).setDepth(D.cards); + // Label positions (local to the seat container). Side seats stack their + // name/chips directly below the portrait, which sits beside the cards; + // other seats keep them on the table-centre side of the cards. + let nameX = 0, chipX = 0, betX = 0, nameY, chipY, betY; + if (isHuman) { + nameY = -CARD_H / 2 - 48; chipY = -CARD_H / 2 - 24; betY = -CARD_H / 2 - 80; + } else if (side) { + nameX = chipX = sp.portraitX - x; + nameY = sp.portraitR + 26; chipY = sp.portraitR + 52; + betX = sp.interiorSign * 140; betY = 0; + } else if (flipped) { + nameY = -CARD_H / 2 - 28; chipY = -CARD_H / 2 - 52; betY = -CARD_H / 2 - 80; + } else { + nameY = CARD_H / 2 + 28; chipY = CARD_H / 2 + 52; betY = CARD_H / 2 + 80; + } + // Name label - const nameY = isHuman ? -CARD_H / 2 - 48 : CARD_H / 2 + 28; - const name = this.add.text(0, nameY, '—', { + const name = this.add.text(nameX, nameY, '—', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setOrigin(0.5); // Chip count - const chipY = isHuman ? -CARD_H / 2 - 24 : CARD_H / 2 + 52; - const chipTxt = this.add.text(0, chipY, '$0', { + const chipTxt = this.add.text(chipX, chipY, '$0', { fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#f0e8d0', }).setOrigin(0.5); - // Bet display (shown at table edge between seat and center) - const betY = isHuman ? -CARD_H / 2 - 80 : (seat === 2 ? CARD_H / 2 + 80 : (y > CY ? -CARD_H / 2 - 80 : CARD_H / 2 + 80)); - const betTxt = this.add.text(0, betY, '', { + // Bet display (shown between the seat and the table centre) + const betTxt = this.add.text(betX, betY, '', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ffd700', @@ -225,7 +300,7 @@ export default class HoldemGame extends Phaser.Scene { this.seatContainers.push({ container, name, chipTxt, betTxt, cardObjs, dealerChip, dealerTxt }); // Portrait (world-space — outside the container) - const { x: px, y: py, portraitR, portraitX, portraitY: portraitYOverride } = SEAT_POS[seat]; + const { x: px, y: py, portraitR, portraitX, portraitY: portraitYOverride } = this.seatPos[seat]; const portraitWorldX = portraitX ?? px; const portraitY = portraitYOverride ?? (isHuman ? py - CARD_H / 2 - 48 - portraitR - 8 // above name label @@ -345,8 +420,10 @@ export default class HoldemGame extends Phaser.Scene { if (check) { this.callBtn.setLabel('Check'); + this.raiseBtn.setLabel('Bet'); } else { this.callBtn.setLabel(`Call $${toCall}`); + this.raiseBtn.setLabel('Raise'); } } @@ -406,7 +483,7 @@ export default class HoldemGame extends Phaser.Scene { // ── Game flow ──────────────────────────────────────────────────────────────── beginGame() { - this.gs = createInitialState(this.opponents.slice(0, 3), this.buyIn); + this.gs = createInitialState(this.opponents.slice(0, 7), this.buyIn); this.dealNextHand(); } @@ -521,13 +598,13 @@ export default class HoldemGame extends Phaser.Scene { // Returns world coords of the "bet zone" in front of a seat (between seat and table centre). betZone(seat) { - const { x, y } = SEAT_POS[seat]; + const { x, y } = this.seatPos[seat]; const dx = CX - x; const dy = CY - y; const len = Math.sqrt(dx * dx + dy * dy); - // Seats 0 and 2 sit on the vertical centre axis; shift their bet zone right - // so chip stacks don't overlap the name/chip-count text centred at CX. - const xOffset = (seat === 0 || seat === 2) ? 160 : 0; + // Seats on the vertical centre axis shift their bet zone right so chip + // stacks don't overlap the name/chip-count text centred at CX. + const xOffset = Math.abs(x - CX) < 120 ? 160 : 0; return { x: x + (dx / len) * 140 + xOffset, y: y + (dy / len) * 100 }; } @@ -544,7 +621,7 @@ export default class HoldemGame extends Phaser.Scene { animateFold(seat, onComplete) { playSound(this, SFX.CARD_PLACE); const sc = this.seatContainers[seat]; - const { x: sx, y: sy } = SEAT_POS[seat]; + const { x: sx, y: sy } = this.seatPos[seat]; const { x: tx, y: ty } = this.betZone(seat); const tempCards = sc.cardObjs @@ -569,8 +646,8 @@ export default class HoldemGame extends Phaser.Scene { if (tempCards.length === 0) { onComplete(); return; } - // Slight rotation: clockwise for left seats, counter for right - const angle = seat === 1 ? -14 : seat === 3 ? 14 : seat === 0 ? 10 : -10; + // Slight rotation: tilt toward the table edge based on which side the seat is on + const angle = seat === 0 ? 10 : (sx < CX ? -14 : sx > CX ? 14 : -10); this.tweens.add({ targets: tempCards, @@ -590,7 +667,7 @@ export default class HoldemGame extends Phaser.Scene { } animateChips(seat, onComplete) { - const { x: sx, y: sy } = SEAT_POS[seat]; + const { x: sx, y: sy } = this.seatPos[seat]; const { x: tx, y: ty } = this.betZone(seat); playChipBet(this); @@ -764,8 +841,13 @@ export default class HoldemGame extends Phaser.Scene { // Animate hole cards flying from the deck (centre) to each seat, one at a time. animateDeal(onComplete) { - // Deal order: one pass around the table, then a second pass (standard poker) - const seatOrder = [1, 2, 3, 0].filter((s) => !this.gs.players[s]?.eliminated); + // Deal order: opponents around the table (seats 1..n-1) then the human (0), + // one pass, then a second pass (standard poker) + const n = this.gs.players.length; + const order = []; + for (let s = 1; s < n; s++) order.push(s); + order.push(0); + const seatOrder = order.filter((s) => !this.gs.players[s]?.eliminated); const sequence = [...seatOrder, ...seatOrder]; // card 0 then card 1 let done = 0; @@ -774,7 +856,7 @@ export default class HoldemGame extends Phaser.Scene { sequence.forEach((seat, idx) => { const cardIdx = idx < seatOrder.length ? 0 : 1; - const { x: sx, y: sy } = SEAT_POS[seat]; + const { x: sx, y: sy } = this.seatPos[seat]; const isHuman = seat === 0; const spacing = isHuman ? CARD_W + 12 : CARD_W * 0.7 + 8; const targetX = sx + (cardIdx === 0 ? -spacing / 2 : spacing / 2); @@ -999,9 +1081,11 @@ export default class HoldemGame extends Phaser.Scene { // ── Action badge ───────────────────────────────────────────────────────────── showActionBadge(seat, action) { - const pos = SEAT_POS[seat]; + const pos = this.seatPos[seat]; const isHuman = seat === 0; - const badgeY = isHuman ? pos.y - CARD_H / 2 - 110 : pos.y + CARD_H / 2 + 70; + const badgeY = isHuman + ? pos.y - CARD_H / 2 - 110 + : pos.flipped ? pos.y - CARD_H / 2 - 70 : pos.y + CARD_H / 2 + 70; const labels = { fold: 'FOLD', check: 'CHECK', call: 'CALL', raise: `RAISE $${action.amount ?? ''}`, allin: 'ALL IN' }; const colors = { fold: '#e06c75', check: '#9e9080', call: COLORS.accentHex, raise: '#ffd700', allin: '#ff6b35' }; @@ -1079,12 +1163,14 @@ export default class HoldemGame extends Phaser.Scene { const divider = this.add.rectangle(CX, 262, 1600, 1, COLORS.muted, 0.4).setDepth(D.modal + 1); S.push(divider); - // Player rows — one per seat, staggered + // Player rows — one per seat, staggered. Pitch shrinks to fit up to 8 rows + // between the divider and the Continue button. const rowStartY = 305; - const rowH = 155; + const rowH = Math.min(155, 700 / players.length); + const scale = Math.min(1, rowH / 155); players.forEach((player, seat) => { const rowY = rowStartY + seat * rowH; - this.buildSummaryRow(player, rowY, winnings, 500 + seat * 190, community, S); + this.buildSummaryRow(player, rowY, winnings, 500 + seat * 190, community, S, rowH, scale); }); // Continue button — fades in after rows have appeared @@ -1096,11 +1182,12 @@ export default class HoldemGame extends Phaser.Scene { this.tweens.add({ targets: continueBtn, alpha: 1, duration: 300, delay: 1100 }); } - buildSummaryRow(player, rowY, winnings, delay, community, S) { + buildSummaryRow(player, rowY, winnings, delay, community, S, rowH = 155, scale = 1) { const isWinner = (winnings.get(player.seat) ?? 0) > 0; // Row panel - const panel = this.add.rectangle(CX, rowY, 1720, 140, COLORS.panel) + const panelH = Math.max(70, rowH - 14); + const panel = this.add.rectangle(CX, rowY, 1720, panelH, COLORS.panel) .setStrokeStyle(2, isWinner ? 0xffd700 : 0x2a2a3a) .setDepth(D.modal + 1).setAlpha(0); S.push(panel); @@ -1108,7 +1195,7 @@ export default class HoldemGame extends Phaser.Scene { // Portrait backing const portX = 145; - const portR = 52; + const portR = Math.round(52 * scale); const backing = this.add.circle(portX, rowY, portR + 3, 0x1a1a2e) .setDepth(D.modal + 1).setAlpha(0); S.push(backing); @@ -1152,7 +1239,7 @@ export default class HoldemGame extends Phaser.Scene { } else { const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase(); const initTxt = this.add.text(portX, rowY, initial, { - fontFamily: 'Righteous', fontSize: '46px', color: COLORS.accentHex, + fontFamily: 'Righteous', fontSize: `${Math.round(46 * scale)}px`, color: COLORS.accentHex, }).setOrigin(0.5).setDepth(D.modal + 2).setAlpha(0); S.push(initTxt); this.tweens.add({ targets: initTxt, alpha: 1, duration: 350, delay }); @@ -1187,15 +1274,14 @@ export default class HoldemGame extends Phaser.Scene { this.tweens.add({ targets: statusTxt, alpha: 1, duration: 350, delay }); } - // Hole cards (0.82× scale → 82×115) - const CW = Math.round(CARD_W * 0.82); - const CH = Math.round(CARD_H * 0.82); + // Hole cards (0.82× scale → 82×115, shrinks further when rows are compact) + const cardScale = 0.82 * scale; const cardBaseX = 490; - const cardOffsets = [-48, 48]; + const cardOffsets = [-48 * scale, 48 * scale]; if (player.hand?.length === 2) { player.hand.forEach((card, ci) => { const cx = cardBaseX + cardOffsets[ci]; - const cont = this.add.container(cx, rowY).setDepth(D.modal + 2).setScale(0.82).setAlpha(0); + const cont = this.add.container(cx, rowY).setDepth(D.modal + 2).setScale(cardScale).setAlpha(0); if (player.folded) { this.renderCard(cont, card, false); this.tweens.add({ targets: cont, alpha: 0.28, duration: 350, delay }); diff --git a/public/src/games/holdem/HoldemLogic.js b/public/src/games/holdem/HoldemLogic.js index 14e2961..08f06e7 100644 --- a/public/src/games/holdem/HoldemLogic.js +++ b/public/src/games/holdem/HoldemLogic.js @@ -254,7 +254,7 @@ export function createInitialState(opponentDefs, buyIn) { eliminated: false, hasActedThisRound: false, }, - ...opponentDefs.slice(0, 3).map((opp, i) => ({ + ...opponentDefs.slice(0, 7).map((opp, i) => ({ seat: i + 1, name: opp.name, chips: buyIn, diff --git a/server/games/registry.js b/server/games/registry.js index 55348f3..be61239 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -26,7 +26,7 @@ export function getGame(slug) { registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 }); registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 }); registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 }); -registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3 }); +registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 7 }); registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 }); registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });