feat(blackjack): expand player capacity to 7 and improve table visuals

- Increase maximum players from 5 to 7 (6 opponents) by updating seat positions, logic loops, and server registry.
- Add traditional felt table markings including curved text for rules ("Blackjack pays 3 to 2", "Insurance pays 2 to 1") and card/bet spot outlines.
- Refactor seat label rendering to use a consistent centered layout under portraits.
- Adjust bet circle positioning logic for the new seat layout.
```
This commit is contained in:
Brian Fertig 2026-05-24 15:46:36 -06:00
parent 1a7decfa0e
commit b2ef3cecf7
3 changed files with 104 additions and 52 deletions

View File

@ -24,12 +24,17 @@ const CARD_SPREAD = 28; // horizontal offset between stacked cards
const DEALER_X = CX;
const DEALER_Y = 420; // ~1/3 down the table ellipse (top ≈190, bottom ≈890)
// 7 seats fanned around the oval: seat 0 (human) anchored bottom-centre,
// 6 opponents fanned 3-per-side (filled outward-alternating so the table stays
// balanced when fewer than 6 opponents are chosen).
const SEAT_POS = [
{ x: CX, y: 860, portraitR: 72, portraitX: CX - 230, portraitY: 860 }, // Human
{ x: 380, y: 775, portraitR: 58, portraitX: 200, portraitY: 650 }, // Opp 1 bottom-left (outside ellipse)
{ x: 150, y: 490, portraitR: 58 }, // Opp 2 left (already outside ellipse)
{ x: 1770, y: 490, portraitR: 58 }, // Opp 3 right (already outside ellipse)
{ x: 1540, y: 775, portraitR: 58, portraitX: 1720, portraitY: 650 }, // Opp 4 bottom-right (outside ellipse)
{ x: CX, y: 860, portraitR: 72, portraitX: CX - 230, portraitY: 860, betX: 1110, betY: 770 }, // 0 Human (bottom centre)
{ x: 1490, y: 745, portraitR: 58, portraitX: 1600, portraitY: 800 }, // 1 right-bottom
{ x: 430, y: 745, portraitR: 58, portraitX: 320, portraitY: 800 }, // 2 left-bottom
{ x: 1670, y: 595, portraitR: 58, portraitX: 1805, portraitY: 610 }, // 3 right-mid
{ x: 250, y: 595, portraitR: 58, portraitX: 115, portraitY: 610 }, // 4 left-mid
{ x: 1585, y: 375, portraitR: 58, portraitX: 1705, portraitY: 335, labelDX: 10 }, // 5 right-top
{ x: 335, y: 375, portraitR: 58, portraitX: 215, portraitY: 335, labelDX: -10 }, // 6 left-top
];
const CHIP_COLORS = { 5: 0xe05c5c, 25: 0x5cb85c, 50: 0x4a90d9, 100: 0x2c2c2c };
@ -82,6 +87,7 @@ export default class BlackjackGame extends Phaser.Scene {
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg).setDepth(D.bg);
this.buildPlayfield();
this.buildTable();
this.buildTableMarkings();
this.buildDealerArea();
this.buildSeats();
this.buildBettingUI();
@ -116,16 +122,77 @@ export default class BlackjackGame extends Phaser.Scene {
g.strokeEllipse(CX, 540, 1440, 640);
}
// ── Traditional felt markings ───────────────────────────────────────────────
// White card-deal outlines + bet circles for every seat (drawn for all 7 so
// unoccupied seats still show open spots), plus curved felt lettering.
buildTableMarkings() {
const g = this.add.graphics().setDepth(D.table + 1);
for (let seat = 0; seat < SEAT_POS.length; seat++) {
const pos = SEAT_POS[seat];
g.lineStyle(2, 0xffffff, 0.5);
g.strokeRoundedRect(
pos.x - CARD_W / 2 - 4, pos.y - CARD_H / 2 - 4,
CARD_W + 8, CARD_H + 8, CARD_R + 2,
);
const { x: bx, y: by } = this.betCirclePos(seat);
g.lineStyle(2, 0xffffff, 0.55);
g.strokeCircle(bx, by, 30);
}
// Curved insurance line + label (concentric with the text arc)
const insBaseY = 512, insRadius = 820;
const insCx = CX, insCy = insBaseY - insRadius;
const insSpan = 0.34;
g.lineStyle(3, 0xffffff, 0.45);
g.beginPath();
g.arc(insCx, insCy, insRadius + 16, Math.PI / 2 - insSpan, Math.PI / 2 + insSpan, false);
g.strokePath();
this.drawArcText('INSURANCE PAYS 2 TO 1', insCx, insBaseY, insRadius, {
fontSize: 20, color: COLORS.textHex, advanceFactor: 0.95,
});
this.drawArcText('BLACKJACK PAYS 3 TO 2', CX, 610, 760, {
fontSize: 30, color: COLORS.goldHex, bold: true, advanceFactor: 0.98,
});
this.drawArcText('DEALER MUST STAND ON ALL 17s · DRAW TO 16', CX, 668, 720, {
fontSize: 19, color: COLORS.mutedHex, advanceFactor: 0.86,
});
}
// Draws a string along a downward-bulging arc, glyph by glyph (Phaser has no
// native curved text). (centerX, baseY) is where the middle of the text sits;
// the line curves upward toward both ends.
drawArcText(text, centerX, baseY, radius, opts = {}) {
const fontSize = opts.fontSize ?? 24;
const anglePer = ((opts.advanceFactor ?? 0.92) * fontSize) / radius;
const cy = baseY - radius; // circle centre, above the text
const start = -anglePer * (text.length - 1) / 2;
const style = {
fontFamily: opts.fontFamily ?? '"Julius Sans One"',
fontSize: `${fontSize}px`,
color: opts.color ?? COLORS.textHex,
...(opts.bold ? { fontStyle: 'bold' } : {}),
};
const depth = opts.depth ?? (D.table + 1);
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === ' ') continue;
const a = start + i * anglePer; // offset from straight-down
this.add.text(centerX + radius * Math.sin(a), cy + radius * Math.cos(a), ch, style)
.setOrigin(0.5)
.setRotation(a)
.setDepth(depth);
}
}
// ── Dealer area ───────────────────────────────────────────────────────────
buildDealerArea() {
this.add.text(CX, 60, 'Blackjack', {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
this.add.text(CX, 110, 'Dealer stands on all 17s · Blackjack pays 3:2', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.ui);
this.dealerScoreTxt = this.add.text(CX, DEALER_Y - CARD_H / 2 - 22, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.ui);
@ -133,7 +200,7 @@ export default class BlackjackGame extends Phaser.Scene {
// ── Seat labels & portraits ───────────────────────────────────────────────
buildSeats() {
for (let seat = 0; seat < 5; seat++) {
for (let seat = 0; seat < SEAT_POS.length; seat++) {
const pos = SEAT_POS[seat];
const player = seat === 0
? { name: 'You', isHuman: true, active: true }
@ -141,45 +208,31 @@ export default class BlackjackGame extends Phaser.Scene {
if (!player.active) continue;
let nameX, nameY, chipX, chipY, labelAnchorX;
if (seat === 0) {
// Human: labels to the left of portrait, right-aligned
const px = pos.portraitX;
const py = pos.portraitY;
nameX = px - pos.portraitR - 14; nameY = py - 14;
chipX = px - pos.portraitR - 14; chipY = py + 14;
labelAnchorX = 1;
} else {
// Opponents: labels beside their portrait circle
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? (pos.y - CARD_H / 2 - pos.portraitR - 68);
const isLeft = pos.x < CX;
const side = isLeft ? 1 : -1;
const labelX = px + side * (pos.portraitR + 14);
nameX = labelX; nameY = py - 14;
chipX = labelX; chipY = py + 14;
labelAnchorX = isLeft ? 0 : 1;
}
// Portrait centre
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? (seat === 0 ? pos.y : pos.y - CARD_H / 2 - pos.portraitR - 68);
this.nameTxts[seat] = this.add.text(nameX, nameY, player.name, {
// Name + bankroll stacked, centred under the portrait
const labelX = px + (pos.labelDX ?? 0);
const nameY = py + pos.portraitR + 18;
const chipY = nameY + 24;
this.nameTxts[seat] = this.add.text(labelX, nameY, player.name, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
}).setOrigin(labelAnchorX, 0.5).setDepth(D.ui);
}).setOrigin(0.5).setDepth(D.ui);
this.chipTxts[seat] = this.add.text(chipX, chipY, '', {
this.chipTxts[seat] = this.add.text(labelX, chipY, '', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(labelAnchorX, 0.5).setDepth(D.ui);
}).setOrigin(0.5).setDepth(D.ui);
// Semi-transparent backing behind both text lines, depth below portrait images/videos
{
const maxTW = Math.max(this.nameTxts[seat].width, 130);
const rectW = maxTW + 20;
const rectH = (chipY - nameY) + 36;
// For right-anchored text the anchor is the RIGHT edge; for left-anchored it's the LEFT edge
const rectCX = labelAnchorX === 1 ? nameX - maxTW / 2 : nameX + maxTW / 2;
const rectCY = (nameY + chipY) / 2;
const bg = this.add.graphics().setDepth(D.ui - 1);
bg.fillStyle(0x000000, 0.60);
bg.fillRoundedRect(rectCX - rectW / 2, rectCY - rectH / 2, rectW, rectH, 6);
bg.fillRoundedRect(labelX - rectW / 2, (nameY + chipY) / 2 - rectH / 2, rectW, rectH, 6);
}
this.scoreTxts[seat] = this.add.text(pos.x, pos.y - CARD_H / 2 - 11, '', {
@ -187,8 +240,6 @@ export default class BlackjackGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.ui);
// Portraits
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? (seat === 0 ? pos.y : pos.y - CARD_H / 2 - pos.portraitR - 68);
if (seat === 0) {
this.portraits[seat] = createPlayerPortrait(this, px, py, pos.portraitR, D.ui, 'BlackjackGame');
} else {
@ -408,7 +459,7 @@ export default class BlackjackGame extends Phaser.Scene {
// ── Render ────────────────────────────────────────────────────────────────
renderAll() {
this.renderDealer();
for (let seat = 0; seat < 5; seat++) {
for (let seat = 0; seat < SEAT_POS.length; seat++) {
const p = this.gs.players[seat];
if (!p.active) continue;
this.renderSeatCards(seat);
@ -505,7 +556,7 @@ export default class BlackjackGame extends Phaser.Scene {
renderBetAreas() {
for (const g of Object.values(this.betGraphics)) g.destroy();
this.betGraphics = {};
for (let seat = 0; seat < 5; seat++) {
for (let seat = 0; seat < SEAT_POS.length; seat++) {
const p = this.gs.players[seat];
if (!p.active || p.bet === 0) continue;
const { x: betX, y: betY } = this.betCirclePos(seat);
@ -534,16 +585,17 @@ export default class BlackjackGame extends Phaser.Scene {
this.betGraphics = {};
}
// Returns the {x,y} of a seat's bet chip circle, placed 40% of the way
// from the portrait toward the table centre (CX, 540).
// Returns the {x,y} of a seat's bet chip circle, placed just inboard of the
// card spot (toward the table centre at CX, 540).
betCirclePos(seat) {
const pos = SEAT_POS[seat];
const px = pos.portraitX ?? pos.x;
const py = pos.portraitY ?? (seat === 0 ? pos.y : pos.y - CARD_H / 2 - pos.portraitR - 68);
const t = 0.40;
if (pos.betX !== undefined && pos.betY !== undefined) {
return { x: pos.betX, y: pos.betY };
}
const t = 0.22;
return {
x: Math.round(px + t * (CX - px)),
y: Math.round(py + t * (540 - py)),
x: Math.round(pos.x + t * (CX - pos.x)),
y: Math.round(pos.y + t * (540 - pos.y)),
};
}
@ -637,7 +689,7 @@ export default class BlackjackGame extends Phaser.Scene {
this.gs = applyBet(this.gs, 0, this.pendingBet);
// AI bets
for (let seat = 1; seat <= 4; seat++) {
for (let seat = 1; seat < SEAT_POS.length; seat++) {
const p = this.gs.players[seat];
if (!p.active) continue;
const bet = chooseBet(p);
@ -956,7 +1008,7 @@ export default class BlackjackGame extends Phaser.Scene {
offerInsurance() {
// AI declines immediately
for (let seat = 1; seat <= 4; seat++) {
for (let seat = 1; seat < SEAT_POS.length; seat++) {
const p = this.gs.players[seat];
if (p.active) this.gs = applyInsurance(this.gs, seat, false);
}

View File

@ -85,7 +85,7 @@ export function createInitialState(opponents, chips) {
},
];
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 6; i++) {
const opp = opponents[i] ?? null;
players.push({
seat: i + 1, name: opp?.name ?? '', isHuman: false, active: !!opp, opponent: opp,

View File

@ -25,7 +25,7 @@ export function getGame(slug) {
// Built-in catalog so the menu has something to show.
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: 5, minOpponents: 0, maxOpponents: 4 });
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: '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 });