feat: add Baccarat casino game with AI opponents and cinematic reveals
- Implement core game logic in BaccaratLogic.js including shoe management, hand evaluation, and betting resolution with standard rules (5% banker commission, 8-to-1 tie payout). - Create BaccaratGame.js Phaser scene with full UI: betting panel, chip selection, seat portraits, and animated card dealing. - Add BaccaratAI.js for opponent bet sizing and type selection (weighted toward Banker). - Introduce "Equation Ribbon" reveal sequence that walks through each card's value, sums them, and performs a mod-10 reduction with visual flair. - Register Baccarat in the server game registry and frontend scene dispatch.
This commit is contained in:
parent
e46cd1cc2f
commit
78466530a3
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { BET } from './BaccaratLogic.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bet-type selection ─────────────────────────────────────────────────────
|
||||||
|
// Weighted toward Banker (the lower house-edge wager), with the occasional
|
||||||
|
// long-shot Tie bet for flavor.
|
||||||
|
export function chooseBetType() {
|
||||||
|
const r = Math.random();
|
||||||
|
if (r < 0.45) return BET.BANKER;
|
||||||
|
if (r < 0.85) return BET.PLAYER;
|
||||||
|
return BET.TIE;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,197 @@
|
||||||
|
// Baccarat (Punto Banco) pure game logic — no Phaser dependencies
|
||||||
|
|
||||||
|
import { SUITS, RANKS } from '../cards/Deck.js';
|
||||||
|
|
||||||
|
// ─── Bet types ──────────────────────────────────────────────────────────────
|
||||||
|
export const BET = { PLAYER: 'player', BANKER: 'banker', TIE: 'tie' };
|
||||||
|
|
||||||
|
// Banker win commission and tie payout (standard table odds).
|
||||||
|
const BANKER_COMMISSION = 0.05;
|
||||||
|
const TIE_PAYOUT = 8;
|
||||||
|
|
||||||
|
// ─── Shoe ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Baccarat point values: A=1, 2–9 face value, 10/J/Q/K = 0.
|
||||||
|
const RANK_VALUE = {
|
||||||
|
'2':2,'3':3,'4':4,'5':5,'6':6,'7':7,'8':8,'9':9,'T':0,'J':0,'Q':0,'K':0,'A':1,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = 8) {
|
||||||
|
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 ──────────────────────────────────────────────────────────
|
||||||
|
// A baccarat hand's value is the sum of its cards modulo 10.
|
||||||
|
export function handValue(cards) {
|
||||||
|
return cards.reduce((sum, c) => sum + c.value, 0) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "natural" is an 8 or 9 on the first two cards — no further draws.
|
||||||
|
export function isNatural(cards) {
|
||||||
|
return cards.length === 2 && (handValue(cards) === 8 || handValue(cards) === 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State creation ───────────────────────────────────────────────────────────
|
||||||
|
export function createInitialState(opponents, chips) {
|
||||||
|
const players = [
|
||||||
|
{
|
||||||
|
seat: 0, name: 'You', isHuman: true, active: true, opponent: null,
|
||||||
|
chips, bet: 0, betType: null,
|
||||||
|
result: null, chipsWon: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
chips: 1000, bet: 0, betType: null,
|
||||||
|
result: null, chipsWon: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: 'betting',
|
||||||
|
playerHand: [],
|
||||||
|
bankerHand: [],
|
||||||
|
outcome: null, // 'player' | 'banker' | 'tie'
|
||||||
|
players,
|
||||||
|
roundNumber: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Round start ──────────────────────────────────────────────────────────────
|
||||||
|
export function prepareRound(gs) {
|
||||||
|
const players = gs.players.map(p => ({
|
||||||
|
...p,
|
||||||
|
bet: 0, betType: null,
|
||||||
|
result: null, chipsWon: 0,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...gs,
|
||||||
|
phase: 'betting',
|
||||||
|
playerHand: [],
|
||||||
|
bankerHand: [],
|
||||||
|
outcome: null,
|
||||||
|
players,
|
||||||
|
roundNumber: gs.roundNumber + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBet(gs, seat, amount, betType) {
|
||||||
|
const players = gs.players.map(p =>
|
||||||
|
p.seat === seat ? { ...p, bet: amount, betType } : p
|
||||||
|
);
|
||||||
|
return { ...gs, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deal a coup ────────────────────────────────────────────────────────────
|
||||||
|
// Deals the Player and Banker hands per the fixed drawing rules. The shoe is
|
||||||
|
// mutated. Returns new gs with both hands and the outcome populated. The deal
|
||||||
|
// order popped from the shoe is P1, B1, P2, B2, then any third cards.
|
||||||
|
export function dealCoup(gs, shoe) {
|
||||||
|
const playerHand = [];
|
||||||
|
const bankerHand = [];
|
||||||
|
|
||||||
|
playerHand.push(shoe.pop()); // P1
|
||||||
|
bankerHand.push(shoe.pop()); // B1
|
||||||
|
playerHand.push(shoe.pop()); // P2
|
||||||
|
bankerHand.push(shoe.pop()); // B2
|
||||||
|
|
||||||
|
const playerNatural = isNatural(playerHand);
|
||||||
|
const bankerNatural = isNatural(bankerHand);
|
||||||
|
|
||||||
|
if (!playerNatural && !bankerNatural) {
|
||||||
|
let playerThird = null;
|
||||||
|
|
||||||
|
// Player draws on 0–5, stands on 6–7.
|
||||||
|
if (handValue(playerHand) <= 5) {
|
||||||
|
const card = shoe.pop();
|
||||||
|
playerHand.push(card);
|
||||||
|
playerThird = card.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Banker drawing rules.
|
||||||
|
const bankerTotal = handValue(bankerHand);
|
||||||
|
let bankerDraws;
|
||||||
|
if (playerThird === null) {
|
||||||
|
// Player stood: banker plays like the player (draw on 0–5).
|
||||||
|
bankerDraws = bankerTotal <= 5;
|
||||||
|
} else if (bankerTotal <= 2) {
|
||||||
|
bankerDraws = true;
|
||||||
|
} else if (bankerTotal === 3) {
|
||||||
|
bankerDraws = playerThird !== 8;
|
||||||
|
} else if (bankerTotal === 4) {
|
||||||
|
bankerDraws = playerThird >= 2 && playerThird <= 7;
|
||||||
|
} else if (bankerTotal === 5) {
|
||||||
|
bankerDraws = playerThird >= 4 && playerThird <= 7;
|
||||||
|
} else if (bankerTotal === 6) {
|
||||||
|
bankerDraws = playerThird >= 6 && playerThird <= 7;
|
||||||
|
} else {
|
||||||
|
bankerDraws = false; // 7 stands
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bankerDraws) bankerHand.push(shoe.pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerTotal = handValue(playerHand);
|
||||||
|
const bankerTotal = handValue(bankerHand);
|
||||||
|
const outcome = playerTotal > bankerTotal ? 'player'
|
||||||
|
: bankerTotal > playerTotal ? 'banker'
|
||||||
|
: 'tie';
|
||||||
|
|
||||||
|
return { ...gs, phase: 'resolved', playerHand, bankerHand, outcome };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resolve bets ─────────────────────────────────────────────────────────────
|
||||||
|
// Returns the net chip delta for a single wager given the coup outcome.
|
||||||
|
export function betPayout(betType, bet, outcome) {
|
||||||
|
if (betType === BET.PLAYER) {
|
||||||
|
if (outcome === 'player') return bet;
|
||||||
|
if (outcome === 'tie') return 0; // bet returned
|
||||||
|
return -bet;
|
||||||
|
}
|
||||||
|
if (betType === BET.BANKER) {
|
||||||
|
if (outcome === 'banker') return bet - Math.floor(bet * BANKER_COMMISSION);
|
||||||
|
if (outcome === 'tie') return 0; // bet returned
|
||||||
|
return -bet;
|
||||||
|
}
|
||||||
|
// Tie
|
||||||
|
if (outcome === 'tie') return bet * TIE_PAYOUT;
|
||||||
|
return -bet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRound(gs) {
|
||||||
|
const players = gs.players.map(p => {
|
||||||
|
if (!p.active || !p.betType || p.bet === 0) return p;
|
||||||
|
const net = betPayout(p.betType, p.bet, gs.outcome);
|
||||||
|
let result;
|
||||||
|
if (net > 0) result = 'win';
|
||||||
|
else if (net < 0) result = 'lose';
|
||||||
|
else result = 'push';
|
||||||
|
return { ...p, chips: p.chips + net, chipsWon: net, result };
|
||||||
|
});
|
||||||
|
return { ...gs, phase: 'resolved', players };
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ import HeartsGame from './games/hearts/HeartsGame.js';
|
||||||
import CatanGame from './games/catan/CatanGame.js';
|
import CatanGame from './games/catan/CatanGame.js';
|
||||||
import NertsGame from './games/nerts/NertsGame.js';
|
import NertsGame from './games/nerts/NertsGame.js';
|
||||||
import BingoGame from './games/bingo/BingoGame.js';
|
import BingoGame from './games/bingo/BingoGame.js';
|
||||||
|
import BaccaratGame from './games/baccarat/BaccaratGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -67,6 +68,7 @@ const config = {
|
||||||
CatanGame,
|
CatanGame,
|
||||||
NertsGame,
|
NertsGame,
|
||||||
BingoGame,
|
BingoGame,
|
||||||
|
BaccaratGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,4 @@ registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true
|
||||||
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||||
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||||
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
|
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
|
||||||
|
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue