feat: add Blackjack game and update game registration

- Introduce BlackjackGame as a new playable game in the client
- Register Blackjack in the server game registry with updated player limits (1-5 players, 0-4 opponents)
- Wire up Blackjack in main.js and GameRoomScene for routing
- Fix OpponentSelectScene to handle games with minOpponents=0
- Remove incomplete ParchisiLogic.js implementation
This commit is contained in:
Brian Fertig 2026-05-16 15:54:39 -06:00
parent 1d28f27a7d
commit 712956a841
8 changed files with 1563 additions and 499 deletions

View File

@ -0,0 +1,57 @@
import { handValue, canDouble, canSplit } from './BlackjackLogic.js';
// ─── Bet sizing ───────────────────────────────────────────────────────────────
export function chooseBet(player) {
const options = [5, 10, 15, 25];
const raw = options[Math.floor(Math.random() * options.length)];
return Math.min(raw, player.chips, 100);
}
// ─── Action selection (basic strategy) ───────────────────────────────────────
export function chooseAction(player, dealerUpcard) {
const hand = player.activeHand === 1 ? player.hand2 : player.hand;
const { score, soft } = handValue(hand);
const upVal = Math.min(dealerUpcard.value, 10); // treat 10/J/Q/K as 10
// Split decisions
if (canSplit(player)) {
const rank = player.hand[0].rank;
if (rank === 'A' || rank === '8') return 'split';
if (rank === '5' || rank === 'T' || rank === 'J' || rank === 'Q' || rank === 'K') {
// never split 5s or 10-values
} else if (upVal >= 2 && upVal <= 6) {
return 'split';
}
}
// Double decisions
if (canDouble(player)) {
if (!soft) {
if (score === 11) return 'double';
if (score === 10 && upVal <= 9) return 'double';
if (score === 9 && upVal >= 3 && upVal <= 6) return 'double';
} else {
// Soft doubles
if ((score === 17 || score === 18) && upVal >= 3 && upVal <= 6) return 'double';
}
}
// Soft totals
if (soft) {
if (score >= 19) return 'stand';
if (score === 18) return upVal >= 9 ? 'hit' : 'stand';
return 'hit';
}
// Hard totals
if (score >= 17) return 'stand';
if (score >= 13 && upVal <= 6) return 'stand';
if (score === 12 && upVal >= 4 && upVal <= 6) return 'stand';
return 'hit';
}
export function chooseInsurance() {
return false; // AI always declines insurance
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,351 @@
// Blackjack pure game logic — no Phaser dependencies
import { SUITS, RANKS } from '../cards/Deck.js';
// ─── Shoe ─────────────────────────────────────────────────────────────────────
const RANK_VALUE = {
'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9,'T':10,'J':10,'Q':10,'K':10,'A':11,
};
function makeCard(rank, suit) {
return {
rank, suit,
value: RANK_VALUE[rank],
label: rank === 'T' ? '10' : rank,
isRed: suit === 'h' || suit === 'd',
suitSymbol: { s:'♠', h:'♥', d:'♦', c:'♣' }[suit],
key: `${rank}${suit}`,
};
}
export function buildShoe(numDecks = 6) {
const cards = [];
for (let d = 0; d < numDecks; d++) {
for (const suit of SUITS) {
for (const rank of RANKS) {
cards.push(makeCard(rank, suit));
}
}
}
for (let i = cards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[cards[i], cards[j]] = [cards[j], cards[i]];
}
return cards;
}
// ─── Hand evaluation ──────────────────────────────────────────────────────────
export function handValue(cards) {
let total = 0;
let aces = 0;
for (const c of cards) {
if (c.rank === 'A') { aces++; total += 11; }
else total += Math.min(c.value, 10);
}
while (total > 21 && aces > 0) { total -= 10; aces--; }
return { score: total, soft: aces > 0 && total <= 21 };
}
export function isBlackjack(hand) {
return hand.length === 2 && handValue(hand).score === 21;
}
export function isBust(hand) {
return handValue(hand).score > 21;
}
export function canDouble(player) {
const hand = player.activeHand === 1 ? player.hand2 : player.hand;
return (hand ?? []).length === 2;
}
export function canSplit(player) {
return (
player.hand.length === 2 &&
player.hand[0].rank === player.hand[1].rank &&
!player.hand2
);
}
// ─── State creation ───────────────────────────────────────────────────────────
export function createInitialState(opponents, chips) {
const players = [
{
seat: 0, name: 'You', isHuman: true, active: true, opponent: null,
chips, bet: 0, bet2: null,
hand: [], hand2: null,
doubled: false, doubled2: false,
insuranceBet: 0,
activeHand: 0,
status: 'waiting', status2: null,
result: null, result2: null, chipsWon: 0,
},
];
for (let i = 0; i < 4; i++) {
const opp = opponents[i] ?? null;
players.push({
seat: i + 1, name: opp?.name ?? '', isHuman: false, active: !!opp, opponent: opp,
chips: 1000, bet: 0, bet2: null,
hand: [], hand2: null,
doubled: false, doubled2: false,
insuranceBet: 0,
activeHand: 0,
status: 'waiting', status2: null,
result: null, result2: null, chipsWon: 0,
});
}
return {
phase: 'betting',
dealer: { hand: [], revealed: false },
players,
currentSeat: 0,
insurancePending: false,
roundNumber: 0,
};
}
// ─── Round start ──────────────────────────────────────────────────────────────
export function prepareRound(gs) {
const players = gs.players.map(p => ({
...p,
bet: 0, bet2: null,
hand: [], hand2: null,
doubled: false, doubled2: false,
insuranceBet: 0,
activeHand: 0,
status: p.active ? 'waiting' : 'waiting',
status2: null,
result: null, result2: null, chipsWon: 0,
}));
return {
...gs,
phase: 'betting',
dealer: { hand: [], revealed: false },
players,
currentSeat: 0,
insurancePending: false,
roundNumber: gs.roundNumber + 1,
};
}
export function applyBet(gs, seat, amount) {
const players = gs.players.map(p => p.seat === seat ? { ...p, bet: amount } : p);
return { ...gs, players };
}
// Deal all cards into state from the shoe (shoe is mutated).
// Returns new gs with hands populated, phase set appropriately.
export function dealAllCards(gs, shoe) {
const players = gs.players.map(p => ({ ...p }));
const dealer = { hand: [], revealed: false };
// Standard deal order: each player card 1, dealer upcard, each player card 2, dealer hole
const activePlayers = players.filter(p => p.active);
// Round 1
for (const p of activePlayers) p.hand.push(shoe.pop());
dealer.hand.push(shoe.pop()); // upcard (index 0)
// Round 2
for (const p of activePlayers) p.hand.push(shoe.pop());
dealer.hand.push(shoe.pop()); // hole card (index 1)
// Detect player blackjacks immediately
for (const p of activePlayers) {
if (isBlackjack(p.hand)) p.status = 'blackjack';
}
const firstActiveSeat = activePlayers[0]?.seat ?? 0;
const dealerUpcard = dealer.hand[0];
const insurancePending = dealerUpcard.rank === 'A';
return {
...gs,
phase: insurancePending ? 'insurance' : 'player_turn',
dealer,
players,
currentSeat: firstActiveSeat,
insurancePending,
};
}
// ─── Insurance ────────────────────────────────────────────────────────────────
export function applyInsurance(gs, seat, accept) {
const players = gs.players.map(p => {
if (p.seat !== seat) return p;
const insuranceBet = accept ? Math.floor(p.bet / 2) : 0;
return { ...p, insuranceBet };
});
return { ...gs, players };
}
// After all insurance decisions: check dealer blackjack
export function resolveInsurance(gs) {
const dealerBJ = isBlackjack(gs.dealer.hand);
const players = gs.players.map(p => {
if (!p.active || p.insuranceBet === 0) return p;
// Insurance bet: pays 2:1 if dealer has BJ, else lost
const insuranceResult = dealerBJ ? p.insuranceBet * 2 : -p.insuranceBet;
return { ...p, chipsWon: (p.chipsWon ?? 0) + insuranceResult };
});
return { ...gs, players, insurancePending: false };
}
// ─── Player actions ───────────────────────────────────────────────────────────
export function applyHit(gs, seat, shoe) {
const players = gs.players.map(p => {
if (p.seat !== seat) return p;
const isH2 = p.activeHand === 1;
const newCard = shoe.pop();
if (isH2) {
const hand2 = [...p.hand2, newCard];
const bust = isBust(hand2);
return { ...p, hand2, status2: bust ? 'bust' : 'playing' };
} else {
const hand = [...p.hand, newCard];
const bust = isBust(hand);
return { ...p, hand, status: bust ? 'bust' : 'playing' };
}
});
return { ...gs, players };
}
export function applyStand(gs, seat) {
const players = gs.players.map(p => {
if (p.seat !== seat) return p;
if (p.activeHand === 1) return { ...p, status2: 'standing' };
// If split exists and hand 1 not yet played, move to hand 2
if (p.hand2 !== null && p.status2 === null) return { ...p, status: 'standing', activeHand: 1 };
return { ...p, status: 'standing' };
});
return { ...gs, players };
}
export function applyDouble(gs, seat, shoe) {
const players = gs.players.map(p => {
if (p.seat !== seat) return p;
const isH2 = p.activeHand === 1;
const newCard = shoe.pop();
if (isH2) {
const hand2 = [...p.hand2, newCard];
const bust = isBust(hand2);
return { ...p, hand2, bet2: p.bet2 * 2, doubled2: true, status2: bust ? 'bust' : 'standing' };
} else {
const hand = [...p.hand, newCard];
const bust = isBust(hand);
const doubled = true;
// If split exists and not yet played hand 2
if (p.hand2 !== null && p.status2 === null) {
return { ...p, hand, bet: p.bet * 2, doubled, status: bust ? 'bust' : 'standing', activeHand: 1 };
}
return { ...p, hand, bet: p.bet * 2, doubled, status: bust ? 'bust' : 'standing' };
}
});
return { ...gs, players };
}
export function applySplit(gs, seat, shoe) {
const players = gs.players.map(p => {
if (p.seat !== seat) return p;
const [c1, c2] = p.hand;
const hand = [c1, shoe.pop()];
const hand2 = [c2, shoe.pop()];
const isAceSplit = c1.rank === 'A';
// Ace splits: each hand gets one card only, immediately stands
return {
...p,
hand, hand2,
bet2: p.bet,
doubled: false, doubled2: false,
status: isAceSplit ? 'standing' : 'playing',
status2: isAceSplit ? 'standing' : null,
activeHand: 0,
};
});
return { ...gs, players };
}
// ─── Dealer turn ──────────────────────────────────────────────────────────────
// Returns new gs with dealer hand fully played out. shoe is mutated.
export function runDealerTurn(gs, shoe) {
const dealer = { ...gs.dealer, revealed: true };
while (true) {
const { score } = handValue(dealer.hand);
if (score >= 17) break;
dealer.hand = [...dealer.hand, shoe.pop()];
}
return { ...gs, phase: 'resolved', dealer };
}
// ─── Resolve round ────────────────────────────────────────────────────────────
export function resolveRound(gs) {
const { score: dealerScore } = handValue(gs.dealer.hand);
const dealerBJ = isBlackjack(gs.dealer.hand);
const dealerBust = dealerScore > 21;
const players = gs.players.map(p => {
if (!p.active) return p;
function resolveHand(hand, bet, status, doubled) {
if (!hand || hand.length === 0) return { result: null, net: 0 };
const { score: playerScore } = handValue(hand);
const playerBJ = isBlackjack(hand);
if (status === 'bust') return { result: 'lose', net: -bet };
if (playerBJ && dealerBJ) return { result: 'push', net: 0 };
if (playerBJ) return { result: 'blackjack', net: Math.floor(bet * 1.5) }; // 3:2
if (dealerBJ) return { result: 'lose', net: -bet };
if (dealerBust) return { result: 'win', net: bet };
if (playerScore > dealerScore) return { result: 'win', net: bet };
if (playerScore === dealerScore) return { result: 'push', net: 0 };
return { result: 'lose', net: -bet };
}
const h1 = resolveHand(p.hand, p.bet, p.status, p.doubled);
const h2 = p.hand2 ? resolveHand(p.hand2, p.bet2, p.status2, p.doubled2) : { result: null, net: 0 };
const totalNet = h1.net + h2.net + (p.chipsWon ?? 0); // chipsWon may have insurance delta
const newChips = p.chips + totalNet;
return {
...p,
chips: newChips,
result: h1.result,
result2: h2.result,
chipsWon: totalNet,
};
});
return { ...gs, phase: 'resolved', players };
}
// ─── Turn advancement ─────────────────────────────────────────────────────────
// Returns the seat number of the next player who still needs to act, or null if done.
export function nextActiveSeat(gs) {
const activePlayers = gs.players.filter(p => p.active);
const currentIdx = activePlayers.findIndex(p => p.seat === gs.currentSeat);
for (let i = currentIdx + 1; i < activePlayers.length; i++) {
const p = activePlayers[i];
if (p.status === 'playing' || p.status === 'waiting') return p.seat;
}
return null; // dealer's turn
}
export function isPlayerDone(player) {
// Hand 1 is done
const h1done = ['standing','bust','blackjack','done'].includes(player.status);
// Hand 2 (if split): must also be done
const h2done = player.hand2 === null || ['standing','bust','blackjack','done'].includes(player.status2);
return h1done && h2done;
}

View File

@ -1,496 +0,0 @@
// Parchisi pure game logic — no Phaser dependencies
export const PLAYER_COLORS = ['yellow', 'green', 'red', 'blue'];
// 52-square outer path as [row, col] pairs, traced clockwise starting at Yellow entry (8,0)
// LEFT arm bottom → left col up → top → junction(6,6) → TOP arm left col → top → right → junction(6,8)
// → RIGHT arm top → right col → bottom → junction(8,8) → BOTTOM arm right → bottom → left → junction(8,6) → close
export const OUTER_PATH = [
// Left arm bottom row (cols 0→5, row 8) — Yellow entry at index 0
[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],
// Left col up (col 5, rows 7→6) — then junction
[7,5],[6,5],
// junction
[6,6],
// Top arm left col (col 6, rows 5→0)
[5,6],[4,6],[3,6],[2,6],[1,6],[0,6],
// top row (row 0, cols 7→8)
[0,7],[0,8],
// Top arm right col (col 8, rows 1→5)
[1,8],[2,8],[3,8],[4,8],[5,8],
// junction
[6,8],
// Right arm top row (row 6, cols 9→14) — Red entry at index 26 → wait, let me recount
// Actually need to recount entries. Let me redo this carefully.
// After junction (6,8): right arm top row cols 9→13, then col 14 rows 6→8
[6,9],[6,10],[6,11],[6,12],[6,13],
// right col (col 14, rows 7→8)
[7,14],[8,14],
// junction
[8,13],[8,12],[8,11],[8,10],[8,9],
// junction
[8,8],
// Bottom arm right col (col 8, rows 9→14)
[9,8],[10,8],[11,8],[12,8],[13,8],[14,8],
// bottom row (row 14, cols 7→6)
[14,7],[14,6],
// Bottom arm left col (col 6, rows 13→9)
[13,6],[12,6],[11,6],[10,6],[9,6],
// junction
[8,6],
// Left arm bottom row back to start (already at [8,0])
// But we need rows 8 cols back... no: left arm row 8 goes col 0→5, we started there.
// Actually from (8,6) we go: left arm col 5 down? No...
// From junction (8,6): we need to get back to (8,0) via row 8 going left?
// But row 8 cols 0-5 is already covered (indices 0-5).
// The path is circular so index 51 should neighbor index 0.
// Let me redo the path properly below.
];
// The above rough draft needs to be carefully constructed. Let me redo it clean:
// Trace clockwise from (8,0):
// 1. Left arm: row 8 from col 0→5 (6 cells, indices 0-5)
// 2. Left col up: col 5 rows 7→6 (2 cells, indices 6-7) — midpoint safe at index 6
// 3. Junction: (6,6) (index 8) -- but wait, (6,5) is NOT a junction, (6,6) is
// Actually left col up to row 6: col 5, rows 7,6 → lands at (6,5) then turn
// Wait: from (8,5) we go up the col: (7,5),(6,5) then into center arm at row 6
// junction is (6,6) - but we need to reach it: (6,5)→(6,6) yes
// Let me re-examine: The left arm spans rows 6-8, cols 0-5 (3 rows, 6 cols)
// Path through left arm:
// Enter at (8,0), go right to (8,5) — bottom row of arm
// Go up col 5: (7,5),(6,5)
// Junction corner (6,6)
// Into top arm
// This gives indices:
// 0:(8,0) 1:(8,1) 2:(8,2) 3:(8,3) 4:(8,4) 5:(8,5) 6:(7,5) 7:(6,5) 8:(6,6)
// Top arm: rows 0-5, cols 6-8
// From (6,6): go up col 6: (5,6)(4,6)(3,6)(2,6)(1,6)(0,6) — 6 cells
// Go right on row 0: (0,7)(0,8) — 2 cells
// Go down col 8: (1,8)(2,8)(3,8)(4,8)(5,8) — 5 cells (skipping (0,8) already done)
// Junction (6,8)
// Indices: 9:(5,6) 10:(4,6) 11:(3,6) 12:(2,6) 13:(1,6) 14:(0,6) — Green entry at 13?
// Wait Green entry should be the first cell of the top arm entering.
// Green enters at (0,6) which would be index 14...
// But plan says Green entry at absolute index 13 → (1,6)?
// Let me recount: 0-8 = 9 cells so far. Top arm:
// 9:(5,6) 10:(4,6) 11:(3,6) 12:(2,6) 13:(1,6) 14:(0,6) 15:(0,7) 16:(0,8)
// 17:(1,8) 18:(2,8) 19:(3,8) 20:(4,8) 21:(5,8) 22:(6,8)
// Right arm: rows 6-8, cols 9-14
// From (6,8): enter at (6,9) going right: (6,9)(6,10)(6,11)(6,12)(6,13)(6,14)
// Go down col 14: (7,14)(8,14)
// Junction (8,13)? No... junction is (8,8).
// Hmm the junctions in the plan were (6,6),(6,8),(8,6),(8,8).
// After going down col 14 to (8,14), we go left on row 8: (8,13)(8,12)(8,11)(8,10)(8,9)
// Then junction (8,8)
// Red entry should be at index 26 = (6,14)? Let me count:
// 23:(6,9) 24:(6,10) 25:(6,11) 26:(6,12)...
// Hmm, Red entry at 26 should be when the path first enters the right arm from the top.
// From the plan: Red entry at absolute index 26 → need to recount carefully.
// I'll just define the correct 52-cell path here directly:
const _OUTER_PATH_CORRECT = [
// Yellow entry (index 0): left arm bottom row L→R
[8,0],[8,1],[8,2],[8,3],[8,4],[8,5],
// up left arm right col
[7,5],[6,5],
// corner junction into top arm
[6,6],
// top arm: up col 6
[5,6],[4,6],[3,6],[2,6],[1,6],
// Green entry (index 13): (0,6) — top-left of top arm
[0,6],
// top arm: across row 0
[0,7],[0,8],
// top arm: down col 8
[1,8],[2,8],[3,8],[4,8],[5,8],
// corner junction into right arm
[6,8],
// right arm: across row 6 R direction
[6,9],[6,10],[6,11],[6,12],[6,13],
// Red entry (index 26): top-right entry = (6,14)? count: 0-5=6, 6-7=2, 8=1, 9-13=5, 14=1, 15-16=2, 17-21=5, 22=1, 23-27=5
// Let me recount: indices 0-5 (6), 6-7 (2), 8 (1) = 9 so far
// 9-13 (5), 14 (1), 15-16 (2), 17-21 (5), 22 (1) = 14 more = 23 total
// 23-27 (5) = right arm row...
// Hmm Red at 26: from index 23=(6,9): 23,24,25,26=(6,12)? That doesn't feel right for top-right corner.
// Plan says Red: 26 → (6,14). So (6,14) should be at index 26.
// Count again: 0:(8,0)..5:(8,5) = 6; 6:(7,5),7:(6,5) = 2; 8:(6,6) = 1; total=9
// 9:(5,6)..13:(1,6) = 5; 14:(0,6) = 1; total=15
// 15:(0,7),16:(0,8) = 2; total=17
// 17:(1,8)..21:(5,8) = 5; total=22
// 22:(6,8) = 1; total=23
// 23:(6,9)..27:(6,13) = 5; total=28
// So (6,14) would be index 28, not 26.
// The safe square at index 26 is the midpoint of the right arm approach.
// Let me check: the plan says "Safe squares at absolute path indices: [0, 6, 13, 19, 26, 32, 39, 45]"
// index 6=(7,5), 13=(1,6), 19=(3,8) - midpoint of top arm
// For right arm midpoint at 26: right arm goes 23-29? midpoint would be 26=(6,11)
// Red entry at absolute 26 must mean (6,11)? But plan says (6,14)...
// Let me re-examine the plan more carefully.
// "Entry indices: Red: 26 → (6,14)"
// So either my path is off by a few, or the junction isn't counted.
// Let me try removing the junction squares from the path (only arm squares):
// Without (6,6) as a separate cell (absorbed into transition):
// 0-5: left bottom, 6-7: left col up, 8-12: top arm up col6, 13:(0,6) Green, 14-15: top row, 16-20: down col8
// 21-25: right arm row6, 26:(6,14)? That works if right arm starts at 21!
// So: no (6,8) junction either — junction is just the corner cell (6,8) = last of col8?
// Actually (5,8) is last of col 8 down...
// Let me try: top arm down col 8 is (1,8)-(5,8) = 5 cells (17-21), then (6,8) = junction = 22
// Then right arm: (6,9)-(6,13) = 5 cells (23-27), (6,14) = 28. Still 28.
// Alternative: top arm only goes (0,6)(0,7)(0,8) across, then immediately down to (5,8),
// and the "left col up" doesn't include (6,5) — maybe just (7,5) and then (6,6) directly?
// i.e. (8,5)→(7,5)→(6,6) skipping (6,5)?
// That would be: 0-5 (6), 6:(7,5), 7:(6,6), 8-12:(5→1,6), 13:(0,6)=Green, 14:(0,7), 15:(0,8),
// 16-20:(1-5,8), 21:(6,8), 22-26:(6,9-13), 27:(6,14)... still 27 not 26.
// Another option: right arm entry (Red) is at first cell entering the right arm from top junction:
// If (6,8) is index 21, then (6,9) = 22... Red at 26 would be (6,13). Hmm.
// OR: the arms are only 2 rows wide instead of 3, meaning the "left col up" piece
// is just one cell: (7,5) and the arm is only rows 7-8 at the sides.
// No, the board is clearly defined as 3-wide arms (3 cells).
// Let me just accept that my plan's entry indices may have been approximate, and instead
// define the path correctly and compute the correct entry indices from it.
// The entry point for each player is where they first enter the outer path from their home base.
// Yellow → bottom-left corner → enters at leftmost cell of left arm bottom row = (8,0)
// Green → top-left corner → enters at top of top arm = (0,6)
// Red → top-right corner → enters at top-right = (6,14)? or (0,8)?
// Red home is top-right (rows 0-5, cols 9-14). Red enters the path at...
// looking at standard Parchisi: each player enters on the cell nearest their home base.
// Red (top-right) → enters at right arm row = (6,14)? or at (0,8)?
// Standard Parchisi: Red enters at the top of the right arm.
// Right arm is rows 6-8, cols 9-14. Red's home is rows 0-5, cols 9-14.
// Red enters path at (6,14) going LEFT then down then left on right arm.
// But wait — the path is clockwise. From Red's home (top-right), going clockwise means
// entering at the top of the right arm and going DOWN the right col then left.
// So Red enters at (6,14) and goes: (6,14)→(7,14)→(8,14)→(8,13)→...→(8,9)→(8,8)→...
// Let me retrace the full clockwise path:
// Standard clockwise from top-left perspective:
// Actually "clockwise" depends on orientation. Let me define it by the standard Parchisi board.
// In standard Parchisi (viewed from above):
// - Yellow starts bottom-left, moves UP the left arm, then across the top, down the right arm, across the bottom
// - So Yellow path: (8,0) right to (8,5), up to (6,5), across to (6,6), up col 6 to (0,6), across row 0 to (0,8), down col 8 to (6,8), right to (6,14)...
// Wait, that's going LEFT across the bottom, which is counter-clockwise...
// Let me think of it differently. Standard Parchisi: each player's tokens move in a consistent direction.
// Yellow (bottom-left) moves: up the left column → across the top → down the right column → across the bottom → back to start
// This is COUNTER-clockwise when viewed from above (standard for most Parchisi implementations).
// Actually, Parchisi traditionally moves tokens clockwise on the board.
// Let me just define a consistent path and make sure all 4 players have proper entry points.
// DEFINITIVE PATH (clockwise from Yellow's perspective at bottom-left):
// Yellow enters at (8,0), moves right along bottom of left arm, then UP the arm's right col to (6,5),
// corner (6,6), up the left col of top arm to (0,6)=Green entry, across top to (0,8),
// down right col of top arm to (6,8)=corner, right along top of right arm to (6,14)=Red entry,
// DOWN the right col to (8,14), left along bottom of right arm to (8,9), corner (8,8),
// down left col of bottom arm to (14,8)=Blue entry, left along bottom to (14,6),
// up right col of bottom arm to (8,6)=corner, and back.
// That gives a path. Let me count:
[8,14],[8,13],[8,12],[8,11],[8,10],[8,9], // placeholder - will be reordered
];
// ─── Final correct OUTER_PATH ───────────────────────────────────────────────
// Traced clockwise. Yellow at index 0, Green at 13, Red at 26, Blue at 39.
const OUTER_PATH_FINAL = (() => {
const path = [];
// Yellow entry (0): left arm, bottom row left→right
for (let c = 0; c <= 5; c++) path.push([8, c]); // 0-5
// left arm, right col up (row 7→6)
path.push([7, 5]); // 6 (safe)
// corner into top arm
path.push([6, 5]); // 7
path.push([6, 6]); // 8
// top arm, left col down→up (row 5→1)
for (let r = 5; r >= 1; r--) path.push([r, 6]); // 9-13 (13=Green entry)
// Green entry (13): (1,6) — first top-arm cell nearest green home
// top arm, top row left→right
path.push([0, 6]); // 14
path.push([0, 7]); // 15
path.push([0, 8]); // 16
// top arm, right col top→bottom (row 1→5)
for (let r = 1; r <= 5; r++) path.push([r, 8]); // 17-21
// corner into right arm
path.push([6, 8]); // 22
path.push([6, 9]); // 23
// right arm, top row left→right
for (let c = 10; c <= 14; c++) path.push([6, c]); // 24-28 (26=Red entry?)
// Hmm still not getting Red at 26. Let me check index 26: 0-5(6) 6(1) 7(1) 8(1) 9-13(5) 14(1) 15(1) 16(1) 17-21(5) 22(1) 23(1) 24-28(5)
// index 26 = path[26]. count: 0-5=6, 6=1(7tot), 7=1(8), 8=1(9), 9-13=5(14), 14=1(15), 15=1(16), 16=1(17), 17-21=5(22), 22=1(23), 23=1(24), 24=1(25), 25=1(26)=[6,11]
// So Red at 26 = (6,11) which is the middle of the right arm. That's the SAFE square, not entry.
// Something is off. Let me try: right arm entry is where Red EXITS their home base.
// Red home is top-right (rows 0-5, cols 9-14). They enter the path via... the right col of the top arm?
// Or they enter at the right side of the right arm top row?
// Standard Parchisi: Red enters at (6,14) = rightmost cell of right arm's top row. = index?
// From my count above, [6,14] would be index 28.
// The plan may have a different path construction. Let me just use whatever makes the game work,
// recalculating the entry indices from the actual path.
return path;
})();
// Build the actual path separately and correctly
function buildOuterPath() {
const p = [];
// Index 0 — Yellow entry
for (let c = 0; c <= 5; c++) p.push([8, c]); // 0-5
p.push([7, 5]); // 6
p.push([6, 5]); // 7
p.push([6, 6]); // 8
for (let r = 5; r >= 1; r--) p.push([r, 6]); // 9-13
p.push([0, 6]); // 14
p.push([0, 7]); // 15
p.push([0, 8]); // 16
for (let r = 1; r <= 5; r++) p.push([r, 8]); // 17-21
p.push([6, 8]); // 22
for (let c = 9; c <= 14; c++) p.push([6, c]); // 23-28
p.push([7, 14]); // 29
p.push([8, 14]); // 30
p.push([8, 13]); // 31
for (let c = 12; c >= 9; c--) p.push([8, c]); // 32-35
p.push([8, 8]); // 36
for (let r = 9; r <= 14; r++) p.push([r, 8]); // 37-42
p.push([14, 7]); // 43
p.push([14, 6]); // 44
for (let r = 13; r >= 9; r--) p.push([r, 6]); // 45-49
p.push([8, 6]); // 50
p.push([8, 7]); // 51
return p;
}
export const OUTER_PATH = buildOuterPath();
// Entry absolute indices (where each player's token appears when entering from home)
// Yellow (seat 0): index 0 = (8,0)
// Green (seat 1): index 13 = (1,6) — top of left col going up
// Red (seat 2): enters from top-right; by symmetry 26 steps from Yellow = index 26
// Index 26 = (6,11) — center of right arm top row (safe square midpoint)
// Actually Red enters at right arm, from (0,8) side going right: after (6,8) junction
// Red home is top-right, they enter path at first cell adjacent to their home.
// In standard Parchisi, Red enters at (6,14) — rightmost of right arm.
// But (6,14) = index 28. Let me just use correct actual indices:
export const ENTRY_INDEX = [0, 13, 28, 41];
// Yellow: 0=(8,0), Green: 13=(1,6), Red: 28=(6,14), Blue: 41=(14,8)
// Recheck: index 41 from path above:
// 0-5(6), 6(7),7(8),8(9), 9-13(14), 14(15),15(16),16(17), 17-21(22), 22(23),
// 23-28(29), 29(30),30(31),31(32), 32-35(36), 36(37),
// 37-42(43), 43(44),44(45), 45-49(50), 50(51),51(52)
// Total = 52 ✓
// Index 41 = 37 is (9,8), 38=(10,8),39=(11,8),40=(12,8),41=(13,8)...
// Blue should enter at (14,8). Let me recount:
// 37=(9,8),38=(10,8),39=(11,8),40=(12,8),41=(13,8),42=(14,8)
// So Blue entry (14,8) = index 42. Fix:
// And also check Green: 9=(5,6),10=(4,6),11=(3,6),12=(2,6),13=(1,6) ✓
// Red: 23=(6,9),24=(6,10),25=(6,11),26=(6,12),27=(6,13),28=(6,14) ✓
// Corrected entry indices:
// Yellow:0, Green:13 ✓, Red:28, Blue:42
// Verify path length = 52:
// 0-5: 6, 6-8: 3, 9-13: 5, 14-16: 3, 17-21: 5, 22-28: 7, 29-31: 3, 32-35: 4, 36: 1, 37-42: 6, 43-44: 2, 45-49: 5, 50-51: 2
// = 6+3+5+3+5+7+3+4+1+6+2+5+2 = 52 ✓
// Safe squares at absolute path indices (entry points + midpoints of each arm)
// Entry: 0,13,28,42
// Midpoints: left arm midpoint ≈ index 6=(7,5), top arm ≈ 19=(3,8), right arm ≈ 32=(8,13), bottom arm ≈ 45=(13,6)
export const SAFE_INDICES = new Set([0, 6, 13, 19, 28, 32, 42, 45]);
// Home columns — 5 squares each player can uniquely enter (relative pos 52-56)
// These are the [row,col] squares leading to center (7,7)
export const HOME_COL = [
// Yellow (seat 0): row 7, cols 1→5 (moving right toward center)
[[7,1],[7,2],[7,3],[7,4],[7,5]],
// Green (seat 1): col 7, rows 1→5 (moving down toward center)
[[1,7],[2,7],[3,7],[4,7],[5,7]],
// Red (seat 2): row 7, cols 13→9 (moving left toward center)
[[7,13],[7,12],[7,11],[7,10],[7,9]],
// Blue (seat 3): col 7, rows 13→9 (moving up toward center)
[[13,7],[12,7],[11,7],[10,7],[9,7]],
];
// Relative position at which a token transitions from outer path to home column
// When token.pos === this value, the NEXT move step enters the home column
// Yellow: just before completing the circuit (51 steps from entry 0, home col is at col 0-5 left arm)
// Actually: Yellow enters home col from (8,6) or (8,7)?
// Yellow home col: row 7, cols 1-5 going right. The outer path near Yellow's entry:
// At index 50=(8,6), 51=(8,7). Yellow's entry is 0=(8,0).
// Relative pos 50 = abs (0+50)%52=50=(8,6), rel 51 = abs 51=(8,7)
// After completing 51 relative steps (abs index 51), the next step enters home col at (7,7)?
// No: home col starts at (7,1). The token at (8,7) moves up to (7,7)=center, but that's the finish.
// The home col should be entered from (8,6) or similar.
// Yellow home col squares: (7,1)-(7,5). Entry from outer path via (8,6)?
// rel pos 50 = (8,6), next step up = (7,6) which isn't in home col...
// Actually Yellow enters home col from left: at abs (8,0) = rel 0, after 51 relative steps = abs (8,7)=rel 51
// Then home col entry: after outer path, token moves from (8,0 side) into row 7...
// In standard Parchisi Yellow's home col is (7,1)(7,2)(7,3)(7,4)(7,5) entered from the left.
// Yellow passes through index 51=(8,7). Next natural step would be (7,7)=center (finish).
// But the home col squares (7,1)-(7,5) are BEFORE (7,7).
// Yellow needs to enter home col from (7,0) which isn't on the outer path.
// OR: Yellow's home col entry is from (8,6) turning up into the left arm of row 7.
// Outer path index 50=(8,6). After rel pos 50, token moves from (8,6) UP to (7,6) then along row 7?
// (7,6) is not in home col... (7,5) is the first home col square.
// This means HOME_COL_ENTRY_POS[0] = 50 (token at rel 50=(8,6), next step goes into home col).
// Let me define HOME_COL_ENTRY_POS as the last OUTER PATH relative position BEFORE entering home col:
// After moving past this position, if steps go beyond it, they enter the home column.
// Yellow (0): rel pos 50 = (8,6) → home col via (8,6)→(7,5)... but these aren't adjacent!
// (8,6) to (7,5) is diagonal. This is wrong.
// OK let me think about this differently.
// In standard Parchisi, the home column is directly in front of each player's home base.
// Yellow's home is bottom-left. Their home column is row 7, cols 1-5 (leftmost non-home col).
// Yellow travels counter-clockwise: starts at (8,0), goes right, up, across, down, and completes
// nearly a full circuit, arriving at (8,6). From (8,6) the token turns and enters the home column
// by going UP: (7,6)→(7,5)? No, that's still wrong directionally.
// I think the home col for Yellow should actually be col 1, rows 8→7→... no.
// Let me look at this from a standard board description:
// Yellow home col: (7,1),(7,2),(7,3),(7,4),(7,5) — these are in the LEFT ARM of the cross.
// Left arm spans rows 6-8, cols 0-5. Row 7 in the left arm.
// Yellow's home col is the MIDDLE ROW of the left arm, going from col 1 to col 5 (toward center).
// To enter: Yellow must travel almost fully around the board, approaching from the right side of the left arm.
// After outer path index 50=(8,6) and 51=(8,7): wait that puts us in the center area.
//
// I think I need to restructure the outer path to NOT include row 7 of the center (8,7).
// After (8,6) (index 50), the path should go to (8,7) is wrong — that's the junction area.
//
// Let me reconsider the path ending. After junction (8,6) from the bottom arm,
// what's left before Yellow's entry at (8,0)?
// The path needs to close: from (8,6) back to (8,0).
// Row 8, col 6→5→4→3→2→1→0. But (8,5) is already index 5, and (8,0) is index 0.
// So the path is NOT a simple row traversal — it must go AROUND.
// Yellow enters at (8,0) heading RIGHT. After nearly a full circuit, they approach from the left,
// which means (8,0) is approached FROM (8,1) going left, i.e., from the loop closing.
// This means the segment after junction (8,6) goes: col 5 in row 8 going left.
// But we need: after (8,6) go to... LEFT ARM'S MIDDLE ROW (7,6)?
// No wait. From the junction (8,6), going toward Yellow's home means going into the LEFT ARM via the top row (row 6).
// From (8,6) → (7,6)? No, from junction at row 8 col 6, moving into left arm means going UP along col 5: no...
//
// I'm overcomplicating this. Let me look at it from first principles:
// The outer path is a closed loop. The left arm occupies rows 6-8, cols 0-5.
// The bottom row of the left arm (row 8, cols 0-5) is where Yellow enters.
// The TOP row of the left arm (row 6, cols 0-5) connects to the center column junction.
// The MIDDLE row of the left arm (row 7, cols 0-5) is Yellow's HOME COLUMN (for arriving tokens).
//
// The outer path uses rows 8 and 6 of the left arm (not row 7).
// Yellow tokens travel: row 8 left→right, then up the right column (col 5), then into the center
// via row 6... but wait, they've already traversed the left arm going right on row 8.
// After nearly a full circuit, Yellow approaches the left arm from the CENTER side:
// Coming from the bottom arm via junction (8,6), the path must enter the left arm from the RIGHT.
// But row 8 of left arm is already the ENTRY row (heading right at start).
// So the closing segment of the path (after Blue's arm) should use ROW 6 of the left arm going LEFT:
// (8,6) junction → (7,6)? → hmm.
//
// Actually in standard boards the outer path uses the OUTER EDGE of each arm:
// Left arm: goes along row 8 (bottom edge, left→right) and row 6 (top edge, right→left) going different directions.
// So the path enters from (8,0) going right to (8,5), then goes up and eventually loops back via row 6 (cols 5→0).
// Row 6 would be the "return" path through the left arm.
// The home column (row 7, cols 1-5) is in the MIDDLE and only accessible by turning off from the outer path.
//
// So the correct path structure for the left arm:
// GOING (start): row 8, cols 0→5 (Yellow enters here)
// After nearly full circuit RETURNING: row 6, cols 5→0 (but this is NOT the outer path for Yellow —
// these are only traversed when nearly done)
// Yellow's home col entry: when they reach col 5 in the "return" segment, they turn into row 7.
//
// So let me rebuild: after the bottom arm, instead of (8,6)→(8,7) I should have:
// junction (8,6) → row 6, cols 5→1 → then home col entry (row 7)
// Wait, the junction from bottom arm leads to LEFT ARM TOP ROW (row 6), not (8,7).
//
// Let me redo the FULL path completely:
return null;
}
// ─── DEFINITIVE IMPLEMENTATION ───────────────────────────────────────────────
// Outer path: 56 squares (standard Parchisi has 52 outer + 4 junction/corner squares)
// Actually let me just hard-code the correct path based on standard Parchisi topology:
const _PATH = [];
// Yellow entry = index 0 = (8,0)
// LEFT ARM bottom row: row 8, cols 0→5
for (let c = 0; c <= 5; c++) _PATH.push([8, c]); // indices 0-5
// Left arm right col going up: col 5, rows 7→6
_PATH.push([7, 5], [6, 5]); // 6, 7 (6=safe)
// Junction into top arm: (6,6)
_PATH.push([6, 6]); // 8
// TOP ARM left col going up: col 6, rows 5→0
for (let r = 5; r >= 0; r--) _PATH.push([r, 6]); // 9-14 (13=Green entry=(1,6); 14=(0,6))
// Top row going right: row 0, cols 7→8
_PATH.push([0, 7], [0, 8]); // 15-16
// Top arm right col going down: col 8, rows 1→5
for (let r = 1; r <= 5; r++) _PATH.push([r, 8]); // 17-21 (19=(3,8)=safe)
// Junction into right arm: (6,8)
_PATH.push([6, 8]); // 22
// RIGHT ARM top row going right: row 6, cols 9→13
for (let c = 9; c <= 13; c++) _PATH.push([6, c]); // 23-27
// Red entry at (6,14): index 28
_PATH.push([6, 14]); // 28 Red entry (safe)
// Right arm right col going down: col 14, rows 7→8
_PATH.push([7, 14], [8, 14]); // 29-30
// Right arm bottom row going left: row 8, cols 13→9
for (let c = 13; c >= 9; c--) _PATH.push([8, c]); // 31-35 (32=(8,13)=safe)
// Junction into bottom arm: (8,8)
_PATH.push([8, 8]); // 36
// BOTTOM ARM right col going down: col 8, rows 9→13
for (let r = 9; r <= 13; r++) _PATH.push([r, 8]); // 37-41 (45 candidate)
// Blue entry at (14,8): index 42
_PATH.push([14, 8]); // 42 Blue entry (safe)
// Bottom row going left: row 14, cols 7→6
_PATH.push([14, 7], [14, 6]); // 43-44
// Bottom arm left col going up: col 6, rows 13→9
for (let r = 13; r >= 9; r--) _PATH.push([r, 6]); // 45-49 (45=(13,6)=safe)
// Junction into left arm: (8,6)
_PATH.push([8, 6]); // 50
// Left arm top row going left: row 6, cols 5→1 — these lead to Yellow's home col entry
// Actually after (8,6) we need to get back to close near (8,0).
// The path closes: from junction (8,6) we move along... row 8 going LEFT: (8,5) is already index 5.
// This is the problem. We can't use row 8 again.
// The path must use row 6 of the left arm (the top edge going left):
_PATH.push([7, 6]); // 51 — last cell before home col entry for Yellow
// This gives 52 cells (0-51). After relative pos 51, Yellow's token enters the home column.
// HOME_COL_ENTRY_POS for Yellow: after relative pos 50 (which is abs (0+50)%52=50=(8,6))
// When token is at rel pos 50, moving 1+ more steps:
// rel 51 = (7,6) — but this is not in Yellow's home col either! Home col is row 7, cols 1-5.
// (7,6) would be the junction. Hmm.
// I think the standard approach is:
// After index 50 (or wherever), the token EXITS the outer path and enters home col.
// HOME_COL_ENTRY_POS[seat] is the relative pos such that:
// if token.pos <= HCEP and token.pos + dice > HCEP, it enters home col.
// The home col entry happens AT the threshold, not after.
// So Yellow: HOME_COL_ENTRY_POS = 51 means: when at rel 51 and rolling a 1-5, they enter home col.
// OR: the token at rel 51=(7,6) can move to home col square (7,5) with 1 step (going left).
// This makes sense! (7,6) → (7,5) = first home col square.
// So the corrected home cols (approaching from the junction side):
// Yellow: at (7,6), moves LEFT into (7,5),(7,4),(7,3),(7,2),(7,1)... wait that's going away from center.
// (7,1) closer to home base (further from center). We want to move toward (7,7)=center.
// So Yellow home col should be: (7,5),(7,4),(7,3),(7,2),(7,1) going left? But center is at (7,7).
// To get to (7,7) from (7,6): one step right = (7,7). But home col should be 5 squares before center.
// Yellow home col = (7,1),(7,2),(7,3),(7,4),(7,5) going RIGHT toward (7,7).
// Yellow enters from (7,0) (their home corner side). But (7,0) isn't on the outer path either.
//
// I think the standard Parchisi board has the home col on the OPPOSITE side of the arm from entry.
// Yellow enters the arm from the bottom (row 8 going right). After traveling the full circuit,
// they come back and enter the ARM from the TOP (row 6 going left toward col 0).
// Their home column is in the MIDDLE ROW (row 7) going RIGHT toward center.
// Entry into home col: when path cell is (6,1) (top of arm near home), token turns down to (7,1) then right.
//
// This is getting very complex. Let me just hard-code a working 52-cell path and matching home cols
// that are geometrically consistent, even if they don't match every cell in the plan exactly.
// The key is that the GAME LOGIC works correctly.
// ─── FINAL DEFINITIVE PATH AND DATA ─────────────────────────────────────────
// I'll use a simpler approach: home col entry is triggered when the outer path
// reaches a specific cell adjacent to the home column corridor.
export { }; // temporary export placeholder

View File

@ -13,6 +13,7 @@ import LobbyScene from './scenes/LobbyScene.js';
import GameRoomScene from './scenes/GameRoomScene.js';
import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js';
import BlackjackGame from './games/blackjack/BlackjackGame.js';
const config = {
type: Phaser.AUTO,
@ -39,6 +40,7 @@ const config = {
GameRoomScene,
BackgammonGame,
HoldemGame,
BlackjackGame,
],
};

View File

@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -58,14 +58,19 @@ export default class OpponentSelectScene extends Phaser.Scene {
return;
}
const min = this.gameDef.minOpponents ?? 1;
this.startBtn = new Button(this, cx, 1048, 'Start Game', () => this.startGame(), { width: 280 });
this.startBtn.setEnabled(false);
this.startBtn.setEnabled(min === 0);
new Button(this, cx, 978, 'Back', () => this.scene.start('GameMenu'), {
variant: 'ghost',
width: 280,
});
for (let i = opponents.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[opponents[i], opponents[j]] = [opponents[j], opponents[i]];
}
this.buildOpponentGrid(opponents);
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],

View File

@ -27,5 +27,5 @@ export function getGame(slug) {
// Built-in placeholders so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 6, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });