Added several opponents and Texas Hold Em
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.env
|
||||
data/
|
||||
public/uploads/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
// Common lifecycle and helpers for any game. Concrete games extend
|
||||
// TabletopGame or CasinoGame, which extend this.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import {
|
||||
cloneState, getValidMoves, applyMove, computePipCount,
|
||||
} from './BackgammonLogic.js';
|
||||
|
||||
// Returns an ordered array of moves for the AI (Black) to execute.
|
||||
export function chooseMoves(state) {
|
||||
const sequences = generateSequences(state);
|
||||
if (sequences.length === 0) return [];
|
||||
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const seq of sequences) {
|
||||
const score = evaluateState(seq.finalState, 'black');
|
||||
if (score > bestScore) { bestScore = score; best = seq; }
|
||||
}
|
||||
return best ? best.moves : [];
|
||||
}
|
||||
|
||||
function generateSequences(state, depth = 0) {
|
||||
if (depth > 6) return [{ moves: [], finalState: state }];
|
||||
|
||||
const validMoves = getValidMoves(state);
|
||||
if (validMoves.length === 0 || state.movesLeft.length === 0) {
|
||||
return [{ moves: [], finalState: state }];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const seenBoards = new Set();
|
||||
|
||||
for (const move of validMoves) {
|
||||
const next = applyMove(state, move);
|
||||
// If applyMove ended the turn, no further moves are possible in this sequence
|
||||
const finished = next.currentPlayer !== state.currentPlayer || next.phase !== 'move';
|
||||
if (finished) {
|
||||
results.push({ moves: [move], finalState: next });
|
||||
} else {
|
||||
const subSeqs = generateSequences(next, depth + 1);
|
||||
for (const sub of subSeqs) {
|
||||
const key = boardHash(sub.finalState);
|
||||
if (!seenBoards.has(key)) {
|
||||
seenBoards.add(key);
|
||||
results.push({ moves: [move, ...sub.moves], finalState: sub.finalState });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : [{ moves: [], finalState: state }];
|
||||
}
|
||||
|
||||
function boardHash(state) {
|
||||
return state.points.map((p) => `${p.color?.[0] ?? '-'}${p.count}`).join('') +
|
||||
`|b${state.bar.black}w${state.bar.white}`;
|
||||
}
|
||||
|
||||
function evaluateState(state, player) {
|
||||
const opp = player === 'white' ? 'black' : 'white';
|
||||
let score = 0;
|
||||
|
||||
// Pip count advantage
|
||||
const ownPips = computePipCount(state, player);
|
||||
const oppPips = computePipCount(state, opp);
|
||||
score += (oppPips - ownPips) * 1.5;
|
||||
|
||||
// Borne off bonus
|
||||
score += state.borneOff[player] * 25;
|
||||
score -= state.borneOff[opp] * 25;
|
||||
|
||||
// Opponent checkers on bar (we sent them there)
|
||||
score += state.bar[opp] * 20;
|
||||
|
||||
// Own blots (lone exposed checkers)
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const pt = state.points[i];
|
||||
if (pt.color === player && pt.count === 1) score -= 8;
|
||||
if (pt.color === player && pt.count >= 2) {
|
||||
// Home board anchors
|
||||
const inHome = player === 'white' ? (i <= 5) : (i >= 18);
|
||||
if (inHome) score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Prime bonus — runs of 3+ consecutive blocked points
|
||||
let run = 0;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if (state.points[i].color === player && state.points[i].count >= 2) {
|
||||
run++;
|
||||
if (run >= 3) score += 15;
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
|
@ -0,0 +1,906 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import {
|
||||
createInitialState, rollDice, getValidMoves,
|
||||
applyMove, endTurn, hasAnyMove, computePipCount,
|
||||
} from './BackgammonLogic.js';
|
||||
import { chooseMoves } from './BackgammonAI.js';
|
||||
|
||||
// ── Layout constants ──────────────────────────────────────────────────────────
|
||||
const BX = 300; // board outer left
|
||||
const BY = 40; // board outer top
|
||||
const BW = 1480; // board outer width
|
||||
const BH = 980; // board outer height
|
||||
const BORDER = 28; // wood border thickness
|
||||
const BAR_W = 72; // center bar width
|
||||
const BEAR_W = 110; // right bear-off column width
|
||||
const FX = BX + BORDER; // felt left
|
||||
const FY = BY + BORDER; // felt top
|
||||
const FW = BW - BORDER * 2;
|
||||
const FH = BH - BORDER * 2;
|
||||
const HALF_W = (FW - BAR_W - BEAR_W) / 2; // ~601
|
||||
const PW = HALF_W / 6; // point width ~100
|
||||
const PH = FH * 0.43; // triangle height ~400
|
||||
const CR = PW * 0.41; // checker radius ~41
|
||||
const BAR_X = FX + HALF_W; // bar left edge
|
||||
const BEAR_X = FX + HALF_W * 2 + BAR_W; // bear-off left edge
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEPTH = { board: 0, checker: 10, highlight: 20, movingChecker: 30, dice: 40, ui: 50, banner: 60 };
|
||||
|
||||
const C = {
|
||||
wood: 0x2c1a0e,
|
||||
woodLight: 0x5c3a1e,
|
||||
felt: 0x0d3b1a,
|
||||
feltLight: 0x124a22,
|
||||
triAmber: 0xc47c1a,
|
||||
triRed: 0x7c1a1a,
|
||||
barWood: 0x241508,
|
||||
wFill: 0xd4c5a0,
|
||||
wRing: 0xf0e8d0,
|
||||
bFill: 0x1a1a2e,
|
||||
bRing: 0x3a3a4e,
|
||||
gold: 0xffd700,
|
||||
dieIvory: 0xf0e8d0,
|
||||
diePip: 0x1a1a1a,
|
||||
};
|
||||
|
||||
export default class BackgammonGame extends Phaser.Scene {
|
||||
constructor() { super('Backgammon'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.gs = null; // game state
|
||||
this.animating = false;
|
||||
this.selectedFrom = null;
|
||||
this.highlightObjs = [];
|
||||
this.checkerObjs = []; // [{ from, stackPos, color, container }]
|
||||
this.diceContainers = [];
|
||||
this.diceGraphics = [];
|
||||
this.rollBtn = null;
|
||||
this.turnText = null;
|
||||
this.statusText = null;
|
||||
this.pipWhiteText = null;
|
||||
this.pipBlackText = null;
|
||||
this.opponentPortrait = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
this.buildParticleTexture();
|
||||
this.buildPlayfield();
|
||||
this.buildBoard();
|
||||
this.buildBearOffArea();
|
||||
this.buildDice();
|
||||
this.buildUI();
|
||||
this.buildPlayerCards();
|
||||
this.initGame();
|
||||
}
|
||||
|
||||
// ── Board Construction ──────────────────────────────────────────────────────
|
||||
|
||||
buildPlayfield() {
|
||||
const pf = this.playfield;
|
||||
if (!pf) return;
|
||||
if (pf.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT)
|
||||
.setDepth(DEPTH.board - 1);
|
||||
} else if (pf.fallbackColor) {
|
||||
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
|
||||
.setDepth(DEPTH.board - 1);
|
||||
}
|
||||
}
|
||||
|
||||
buildParticleTexture() {
|
||||
const g = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('bgParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildBoard() {
|
||||
const g = this.add.graphics().setDepth(DEPTH.board);
|
||||
|
||||
// Outer wood border
|
||||
g.fillStyle(C.wood, 1);
|
||||
g.fillRoundedRect(BX, BY, BW, BH, 8);
|
||||
g.lineStyle(3, C.woodLight, 1);
|
||||
g.strokeRoundedRect(BX + 4, BY + 4, BW - 8, BH - 8, 6);
|
||||
g.lineStyle(1, 0x8b5c2a, 0.5);
|
||||
g.strokeRoundedRect(BX + 2, BY + 2, BW - 4, BH - 4, 7);
|
||||
|
||||
// Felt surface
|
||||
g.fillStyle(C.felt, 1);
|
||||
g.fillRect(FX, FY, FW, FH);
|
||||
|
||||
// Subtle felt texture lines
|
||||
g.lineStyle(1, C.feltLight, 0.12);
|
||||
for (let y = FY + 20; y < FY + FH; y += 30) {
|
||||
g.lineBetween(FX, y, FX + FW, y);
|
||||
}
|
||||
|
||||
// Center bar
|
||||
g.fillStyle(C.barWood, 1);
|
||||
g.fillRect(BAR_X, FY, BAR_W, FH);
|
||||
g.lineStyle(2, C.woodLight, 0.7);
|
||||
g.strokeRect(BAR_X, FY, BAR_W, FH);
|
||||
|
||||
// 24 triangles
|
||||
for (let idx = 0; idx < 24; idx++) {
|
||||
const color = idx % 2 === 0 ? C.triAmber : C.triRed;
|
||||
g.fillStyle(color, 0.9);
|
||||
const { ax, bx, tipY, baseY } = this.triangleCoords(idx);
|
||||
g.fillTriangle(ax, baseY, bx, baseY, (ax + bx) / 2, tipY);
|
||||
// Subtle triangle outline
|
||||
g.lineStyle(1, 0x000000, 0.2);
|
||||
g.strokeTriangle(ax, baseY, bx, baseY, (ax + bx) / 2, tipY);
|
||||
}
|
||||
|
||||
// Point number labels
|
||||
for (let idx = 0; idx < 24; idx++) {
|
||||
const label = String(idx + 1);
|
||||
const { ax, bx } = this.triangleCoords(idx);
|
||||
const cx = (ax + bx) / 2;
|
||||
const isBottom = idx < 12;
|
||||
const ly = isBottom ? FY + FH + 14 : FY - 14;
|
||||
this.add.text(cx, ly, label, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '18px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
}
|
||||
|
||||
// Outer board decorative corner inlays
|
||||
g.fillStyle(C.woodLight, 0.3);
|
||||
g.fillRect(BX, BY, 28, 28);
|
||||
g.fillRect(BX + BW - 28, BY, 28, 28);
|
||||
g.fillRect(BX, BY + BH - 28, 28, 28);
|
||||
g.fillRect(BX + BW - 28, BY + BH - 28, 28, 28);
|
||||
}
|
||||
|
||||
buildBearOffArea() {
|
||||
const g = this.add.graphics().setDepth(DEPTH.board);
|
||||
const midY = FY + FH / 2;
|
||||
|
||||
// Background panel
|
||||
g.fillStyle(C.barWood, 1);
|
||||
g.fillRect(BEAR_X, FY, BEAR_W, FH);
|
||||
g.lineStyle(2, C.woodLight, 0.6);
|
||||
g.strokeRect(BEAR_X, FY, BEAR_W, FH);
|
||||
|
||||
// Divider line
|
||||
g.lineStyle(1, C.woodLight, 0.4);
|
||||
g.lineBetween(BEAR_X, midY, BEAR_X + BEAR_W, midY);
|
||||
|
||||
// Labels
|
||||
this.add.text(BEAR_X + BEAR_W / 2, FY + 18, 'OFF', {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
|
||||
this.add.text(BEAR_X + BEAR_W / 2, FY + FH - 18, 'OFF', {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
|
||||
// Pip count labels
|
||||
this.pipBlackText = this.add.text(BEAR_X + BEAR_W / 2, FY + FH / 4, '0', {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '28px', color: '#3a3a4e',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.pipWhiteText = this.add.text(BEAR_X + BEAR_W / 2, FY + 3 * FH / 4, '0', {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '28px', color: '#d4c5a0',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildDice() {
|
||||
const diceY = FY + FH / 2;
|
||||
const barCX = BAR_X + BAR_W / 2;
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const dy = diceY + (i === 0 ? -50 : 50);
|
||||
const g = this.add.graphics();
|
||||
const container = this.add.container(barCX, dy).setDepth(DEPTH.dice);
|
||||
container.add(g);
|
||||
this.diceContainers.push(container);
|
||||
this.diceGraphics.push(g);
|
||||
this.renderDieFace(i, 1);
|
||||
container.setAlpha(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
renderDieFace(dieIndex, value) {
|
||||
const g = this.diceGraphics[dieIndex];
|
||||
const s = 36; // half-size
|
||||
g.clear();
|
||||
|
||||
// Face
|
||||
g.fillStyle(C.dieIvory, 1);
|
||||
g.fillRoundedRect(-s, -s, s * 2, s * 2, 8);
|
||||
g.lineStyle(2, C.wood, 1);
|
||||
g.strokeRoundedRect(-s, -s, s * 2, s * 2, 8);
|
||||
|
||||
// Pip positions (normalized -1 to 1 mapped to ±22px)
|
||||
const pipLayouts = {
|
||||
1: [[0, 0]],
|
||||
2: [[-0.6, -0.6], [0.6, 0.6]],
|
||||
3: [[-0.6, -0.6], [0, 0], [0.6, 0.6]],
|
||||
4: [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]],
|
||||
5: [[-0.6, -0.6], [0.6, -0.6], [0, 0], [-0.6, 0.6], [0.6, 0.6]],
|
||||
6: [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0], [0.6, 0], [-0.6, 0.6], [0.6, 0.6]],
|
||||
};
|
||||
const pips = pipLayouts[value] ?? pipLayouts[1];
|
||||
g.fillStyle(C.diePip, 1);
|
||||
for (const [px, py] of pips) {
|
||||
g.fillCircle(px * 22, py * 22, 6);
|
||||
}
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
const cx = BX + BW / 2;
|
||||
|
||||
// Status message (also serves as turn label at bottom)
|
||||
this.turnText = this.add.text(cx, BY + BH + 18, '', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '24px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.statusText = this.add.text(cx, BY + BH + 46, '', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '22px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
// Roll dice button — inside bar area
|
||||
const barCX = BAR_X + BAR_W / 2;
|
||||
this.rollBtn = new Button(this, barCX, FY + FH / 2 + 130, 'Roll', () => this.onRollClick(), {
|
||||
width: 62, height: 36, fontSize: 18,
|
||||
});
|
||||
this.rollBtn.setDepth(DEPTH.ui);
|
||||
|
||||
// Leave button
|
||||
new Button(this, BX - 64, BY + BH / 2, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 110, height: 44, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
||||
// New game button
|
||||
new Button(this, BX - 64, BY + BH / 2 + 60, 'New', () => this.initGame(), {
|
||||
variant: 'ghost', width: 110, height: 44, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Player / Opponent Cards ─────────────────────────────────────────────────
|
||||
|
||||
buildPlayerCards() {
|
||||
const opp = this.opponents[0];
|
||||
const r = 80;
|
||||
const depth = DEPTH.ui;
|
||||
const avatarX = BX / 2; // centered in the left strip
|
||||
|
||||
// Opponent avatar (upper-left)
|
||||
const oppAY = FY + r + 20;
|
||||
// Decorative wood-tone outer ring matching the board rail
|
||||
this.add.circle(avatarX, oppAY, r + 5, C.barWood).setDepth(depth);
|
||||
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
|
||||
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '18px',
|
||||
color: COLORS.textHex, wordWrap: { width: 240 }, align: 'center',
|
||||
}).setOrigin(0.5, 0).setDepth(depth + 2);
|
||||
|
||||
// Player avatar (lower-left)
|
||||
const plrAY = FY + FH - r - 20;
|
||||
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
|
||||
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Backgammon');
|
||||
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '18px',
|
||||
color: COLORS.textHex, wordWrap: { width: 240 }, align: 'center',
|
||||
}).setOrigin(0.5, 1).setDepth(depth + 2);
|
||||
}
|
||||
|
||||
playOpponentEmotion(emotion) {
|
||||
this.opponentPortrait?.playEmotion(emotion);
|
||||
}
|
||||
|
||||
// ── Game State ──────────────────────────────────────────────────────────────
|
||||
|
||||
initGame() {
|
||||
this.clearCheckers();
|
||||
this.clearHighlights();
|
||||
this.animating = false;
|
||||
this.selectedFrom = null;
|
||||
this.gs = createInitialState();
|
||||
this.renderAll();
|
||||
this.setStatus('');
|
||||
this.showTurnBanner('Your Turn');
|
||||
}
|
||||
|
||||
renderAll() {
|
||||
this.clearCheckers();
|
||||
this.renderCheckers();
|
||||
this.renderBarCheckers();
|
||||
this.renderBorneOff();
|
||||
this.updateDiceDisplay();
|
||||
this.updatePipLabels();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
clearCheckers() {
|
||||
for (const obj of this.checkerObjs) obj.container.destroy();
|
||||
this.checkerObjs = [];
|
||||
}
|
||||
|
||||
renderCheckers() {
|
||||
for (let idx = 0; idx < 24; idx++) {
|
||||
const pt = this.gs.points[idx];
|
||||
if (!pt.color || pt.count === 0) continue;
|
||||
const max = Math.min(pt.count, 5);
|
||||
for (let s = 0; s < max; s++) {
|
||||
const pos = this.checkerScreenPos(idx, s);
|
||||
const c = this.makeChecker(pt.color, pos.x, pos.y);
|
||||
c.setDepth(DEPTH.checker);
|
||||
c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, CR), hitAreaCallback: Phaser.Geom.Circle.Contains });
|
||||
c.on('pointerdown', () => this.onCheckerClick(idx));
|
||||
this.checkerObjs.push({ from: idx, stackPos: s, color: pt.color, container: c });
|
||||
}
|
||||
// Stack count badge
|
||||
if (pt.count > 5) {
|
||||
const pos = this.checkerScreenPos(idx, 4);
|
||||
this.add.text(pos.x, pos.y, String(pt.count), {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '22px',
|
||||
color: pt.color === 'white' ? '#2c1a0e' : '#f0e8d0',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.checker + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderBarCheckers() {
|
||||
const barCX = BAR_X + BAR_W / 2;
|
||||
for (const color of ['white', 'black']) {
|
||||
const count = this.gs.bar[color];
|
||||
const baseY = color === 'white' ? FY + FH * 0.72 : FY + FH * 0.28;
|
||||
for (let s = 0; s < Math.min(count, 4); s++) {
|
||||
const dy = color === 'white' ? s * (CR * 2 + 2) : -(s * (CR * 2 + 2));
|
||||
const c = this.makeChecker(color, barCX, baseY + dy);
|
||||
c.setDepth(DEPTH.checker);
|
||||
if (color === this.gs.currentPlayer) {
|
||||
c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, CR), hitAreaCallback: Phaser.Geom.Circle.Contains });
|
||||
c.on('pointerdown', () => this.onCheckerClick('bar'));
|
||||
}
|
||||
this.checkerObjs.push({ from: 'bar', stackPos: s, color, container: c });
|
||||
}
|
||||
if (count > 4) {
|
||||
const dy = color === 'white' ? 3 * (CR * 2 + 2) : -(3 * (CR * 2 + 2));
|
||||
this.add.text(barCX, baseY + dy, `×${count}`, {
|
||||
fontFamily: 'system-ui, sans-serif', fontSize: '20px',
|
||||
color: color === 'white' ? COLORS.textHex : COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.checker + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderBorneOff() {
|
||||
const cx = BEAR_X + BEAR_W / 2;
|
||||
for (const color of ['white', 'black']) {
|
||||
const count = this.gs.borneOff[color];
|
||||
const baseY = color === 'white' ? FY + FH * 0.82 : FY + FH * 0.18;
|
||||
const rows = Math.min(count, 15);
|
||||
const colW = 18, rowH = 18;
|
||||
for (let i = 0; i < rows; i++) {
|
||||
const col = i % 5, row = Math.floor(i / 5);
|
||||
const dx = (col - 2) * colW;
|
||||
const dy = color === 'white' ? -row * rowH : row * rowH;
|
||||
const g2 = this.add.graphics().setDepth(DEPTH.checker);
|
||||
g2.fillStyle(color === 'white' ? C.wRing : C.bRing, 1);
|
||||
g2.fillCircle(cx + dx, baseY + dy, 7);
|
||||
const c2 = this.add.container(0, 0, [g2]);
|
||||
this.checkerObjs.push({ from: 'off', stackPos: i, color, container: c2 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
makeChecker(color, x, y) {
|
||||
const g = this.add.graphics();
|
||||
// Shadow
|
||||
g.fillStyle(0x000000, 0.25);
|
||||
g.fillCircle(3, 4, CR);
|
||||
// Outer ring
|
||||
g.fillStyle(color === 'white' ? C.wRing : C.bRing, 1);
|
||||
g.fillCircle(0, 0, CR);
|
||||
// Inner fill
|
||||
g.fillStyle(color === 'white' ? C.wFill : C.bFill, 1);
|
||||
g.fillCircle(0, 0, CR - 5);
|
||||
// Highlight arc (top-left sheen)
|
||||
g.lineStyle(3, color === 'white' ? 0xffffff : 0x6a6a8e, 0.55);
|
||||
g.beginPath();
|
||||
g.arc(0, 0, CR - 8, Phaser.Math.DegToRad(200), Phaser.Math.DegToRad(320));
|
||||
g.strokePath();
|
||||
|
||||
const container = this.add.container(x, y, [g]);
|
||||
return container;
|
||||
}
|
||||
|
||||
// ── Screen Coordinate Helpers ───────────────────────────────────────────────
|
||||
|
||||
// Returns { ax, bx, tipY, baseY } for triangle drawing
|
||||
triangleCoords(idx) {
|
||||
const isBottom = idx < 12;
|
||||
let col;
|
||||
if (idx < 6) col = 5 - idx; // bottom-right: idx 0 rightmost (point 1), idx 5 leftmost (point 6)
|
||||
else if (idx < 12) col = 11 - idx; // bottom-left: idx 11 leftmost (point 12), idx 6 rightmost (point 7)
|
||||
else if (idx < 18) col = idx - 12; // top-left: idx 12 leftmost (point 13), idx 17 rightmost (point 18)
|
||||
else col = idx - 18; // top-right: idx 18 leftmost (point 19), idx 23 rightmost (point 24)
|
||||
|
||||
const isRight = idx < 6 || idx >= 18;
|
||||
const halfStart = isRight ? BAR_X + BAR_W : FX;
|
||||
const ax = halfStart + col * PW;
|
||||
const bx = ax + PW;
|
||||
const baseY = isBottom ? FY + FH : FY;
|
||||
const tipY = isBottom ? FY + FH - PH : FY + PH;
|
||||
return { ax, bx, tipY, baseY };
|
||||
}
|
||||
|
||||
// Center x of a point
|
||||
pointCX(idx) {
|
||||
const { ax, bx } = this.triangleCoords(idx);
|
||||
return (ax + bx) / 2;
|
||||
}
|
||||
|
||||
// Screen position of nth checker on a point (stack 0 = closest to board edge)
|
||||
checkerScreenPos(idx, stackPos) {
|
||||
const isBottom = idx < 12;
|
||||
const cx = this.pointCX(idx);
|
||||
const edge = isBottom ? FY + FH : FY;
|
||||
const dir = isBottom ? -1 : 1;
|
||||
const visualStack = Math.min(stackPos, 4);
|
||||
const y = edge + dir * (CR + visualStack * CR * 2);
|
||||
return { x: cx, y };
|
||||
}
|
||||
|
||||
barScreenPos(color) {
|
||||
return {
|
||||
x: BAR_X + BAR_W / 2,
|
||||
y: color === 'white' ? FY + FH * 0.72 : FY + FH * 0.28,
|
||||
};
|
||||
}
|
||||
|
||||
bearOffScreenPos(color) {
|
||||
return {
|
||||
x: BEAR_X + BEAR_W / 2,
|
||||
y: color === 'white' ? FY + FH * 0.82 : FY + FH * 0.18,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
|
||||
onCheckerClick(fromIdx) {
|
||||
if (this.animating) return;
|
||||
if (this.gs.phase !== 'move') return;
|
||||
if (this.gs.currentPlayer !== 'white') return;
|
||||
|
||||
// If bar has white checkers, only bar selection is valid
|
||||
if (this.gs.bar.white > 0 && fromIdx !== 'bar') return;
|
||||
|
||||
const pt = fromIdx === 'bar'
|
||||
? { color: 'white', count: this.gs.bar.white }
|
||||
: this.gs.points[fromIdx];
|
||||
if (pt.color !== 'white' || pt.count === 0) return;
|
||||
|
||||
const validMoves = getValidMoves(this.gs).filter((m) => m.from === fromIdx);
|
||||
if (validMoves.length === 0) {
|
||||
this.flashNoMove(fromIdx);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearHighlights();
|
||||
this.selectedFrom = fromIdx;
|
||||
this.pulseSelectedChecker(fromIdx);
|
||||
this.showHighlights(validMoves);
|
||||
}
|
||||
|
||||
pulseSelectedChecker(fromIdx) {
|
||||
const obj = this.checkerObjs.find(
|
||||
(o) => o.from === fromIdx && o.color === 'white' && o.stackPos === 0
|
||||
);
|
||||
if (!obj) return;
|
||||
// Gold ring overlay
|
||||
const ring = this.add.graphics().setDepth(DEPTH.highlight);
|
||||
ring.lineStyle(4, C.gold, 1);
|
||||
ring.strokeCircle(obj.container.x, obj.container.y, CR + 4);
|
||||
this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.3 }, duration: 500, yoyo: true, repeat: -1 });
|
||||
this.highlightObjs.push(ring);
|
||||
}
|
||||
|
||||
showHighlights(moves) {
|
||||
const destinations = [...new Set(moves.map((m) => m.to))];
|
||||
for (const dest of destinations) {
|
||||
let hx, hy;
|
||||
if (dest === 'off') {
|
||||
const pos = this.bearOffScreenPos('white');
|
||||
hx = pos.x; hy = pos.y;
|
||||
} else {
|
||||
const destStack = this.stackHeight(dest);
|
||||
hx = this.pointCX(dest);
|
||||
hy = this.checkerScreenPos(dest, destStack).y;
|
||||
}
|
||||
|
||||
// Pulsing dot
|
||||
const dot = this.add.graphics().setDepth(DEPTH.highlight);
|
||||
dot.fillStyle(COLORS.accent, 0.85);
|
||||
dot.fillCircle(hx, hy, 20);
|
||||
dot.lineStyle(3, 0xffffff, 0.4);
|
||||
dot.strokeCircle(hx, hy, 20);
|
||||
this.tweens.add({ targets: dot, alpha: { from: 0.85, to: 0.2 }, duration: 600, yoyo: true, repeat: -1 });
|
||||
|
||||
// Invisible hit zone
|
||||
const zone = this.add.zone(hx, hy, PW, CR * 4)
|
||||
.setInteractive({ useHandCursor: true })
|
||||
.setDepth(DEPTH.highlight);
|
||||
zone.on('pointerdown', () => this.onDestinationClick(dest));
|
||||
|
||||
this.highlightObjs.push(dot, zone);
|
||||
}
|
||||
}
|
||||
|
||||
clearHighlights() {
|
||||
for (const obj of this.highlightObjs) obj.destroy();
|
||||
this.highlightObjs = [];
|
||||
this.selectedFrom = null;
|
||||
}
|
||||
|
||||
onDestinationClick(toIdx) {
|
||||
if (this.animating || this.selectedFrom === null) return;
|
||||
const validMoves = getValidMoves(this.gs).filter(
|
||||
(m) => m.from === this.selectedFrom && m.to === toIdx
|
||||
);
|
||||
if (validMoves.length === 0) return;
|
||||
// Prefer smaller die to preserve flexibility
|
||||
const move = validMoves.sort((a, b) => a.dieUsed - b.dieUsed)[0];
|
||||
this.clearHighlights();
|
||||
this.executePlayerMove(move);
|
||||
}
|
||||
|
||||
flashNoMove(fromIdx) {
|
||||
const obj = this.checkerObjs.find((o) => o.from === fromIdx && o.color === 'white');
|
||||
if (!obj) return;
|
||||
this.tweens.add({ targets: obj.container, alpha: { from: 1, to: 0.2 }, duration: 120, yoyo: true, repeat: 3 });
|
||||
}
|
||||
|
||||
// ── Move Execution ──────────────────────────────────────────────────────────
|
||||
|
||||
executePlayerMove(move) {
|
||||
this.animating = true;
|
||||
this.updateButtonStates();
|
||||
|
||||
// Find which container to animate (topmost checker of that point)
|
||||
const obj = this.checkerObjs.filter((o) => o.from === move.from && o.color === 'white')
|
||||
.sort((a, b) => b.stackPos - a.stackPos)[0];
|
||||
|
||||
const fromPos = move.from === 'bar'
|
||||
? this.barScreenPos('white')
|
||||
: this.checkerScreenPos(move.from, obj ? obj.stackPos : 0);
|
||||
|
||||
const toPos = move.to === 'off'
|
||||
? this.bearOffScreenPos('white')
|
||||
: this.checkerScreenPos(move.to, this.stackHeight(move.to));
|
||||
|
||||
const animContainer = obj ? obj.container : null;
|
||||
if (animContainer) animContainer.setDepth(DEPTH.movingChecker);
|
||||
|
||||
this.animateArc(animContainer, fromPos, toPos, () => {
|
||||
this.gs = applyMove(this.gs, move);
|
||||
this.renderAll();
|
||||
this.animating = false;
|
||||
this.updateButtonStates();
|
||||
|
||||
if (move.hit) this.playOpponentEmotion('upset');
|
||||
else if (move.to === 'off') this.playOpponentEmotion('upset');
|
||||
|
||||
if (this.gs.phase === 'game_over') {
|
||||
this.onGameOver();
|
||||
} else if (this.gs.currentPlayer === 'black' && this.gs.phase === 'roll') {
|
||||
this.time.delayedCall(800, () => this.startAITurn());
|
||||
}
|
||||
});
|
||||
|
||||
// Animate hit separately if needed
|
||||
if (move.hit) {
|
||||
const hitObj = this.checkerObjs.find((o) => o.from === move.to && o.color === 'black');
|
||||
if (hitObj) {
|
||||
const barPos = this.barScreenPos('black');
|
||||
this.animateSlide(hitObj.container, barPos, 250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stackHeight(pointIdx) {
|
||||
if (pointIdx === 'off' || pointIdx === 'bar') return 0;
|
||||
const pt = this.gs.points[pointIdx];
|
||||
return pt.color !== null ? pt.count : 0;
|
||||
}
|
||||
|
||||
// ── Dice ────────────────────────────────────────────────────────────────────
|
||||
|
||||
onRollClick() {
|
||||
if (this.animating || this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'white') return;
|
||||
this.rollBtn.setEnabled(false);
|
||||
this.animating = true;
|
||||
|
||||
const finalState = rollDice(this.gs);
|
||||
const [d1, d2] = finalState.dice;
|
||||
|
||||
this.animateDiceRoll([d1, d2], () => {
|
||||
this.gs = finalState;
|
||||
this.updateDiceDisplay();
|
||||
this.animating = false;
|
||||
|
||||
if (d1 === d2) this.playOpponentEmotion('upset');
|
||||
|
||||
if (!hasAnyMove(this.gs)) {
|
||||
this.setStatus('No moves available — turn passed');
|
||||
this.time.delayedCall(1800, () => {
|
||||
this.gs = endTurn(this.gs);
|
||||
this.setStatus('');
|
||||
this.renderAll();
|
||||
this.time.delayedCall(600, () => this.startAITurn());
|
||||
});
|
||||
} else {
|
||||
this.updateButtonStates();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
animateDiceRoll(finalValues, onComplete) {
|
||||
this.diceContainers.forEach((c) => c.setAlpha(1));
|
||||
let elapsed = 0;
|
||||
const totalMs = 700;
|
||||
const phases = [{ until: 400, interval: 60 }, { until: 580, interval: 90 }, { until: totalMs, interval: 130 }];
|
||||
|
||||
const tick = () => {
|
||||
const phaseInterval = phases.find((p) => elapsed < p.until)?.interval ?? 60;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
this.renderDieFace(i, Phaser.Math.Between(1, 6));
|
||||
}
|
||||
elapsed += phaseInterval;
|
||||
if (elapsed < totalMs) {
|
||||
this.time.delayedCall(phaseInterval, tick);
|
||||
} else {
|
||||
this.renderDieFace(0, finalValues[0]);
|
||||
this.renderDieFace(1, finalValues[1]);
|
||||
// Landing pulse
|
||||
for (const c of this.diceContainers) {
|
||||
this.tweens.add({ targets: c, scaleX: 1.2, scaleY: 1.2, duration: 80, yoyo: true });
|
||||
}
|
||||
this.time.delayedCall(120, onComplete);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
updateDiceDisplay() {
|
||||
if (!this.gs.dice) {
|
||||
this.diceContainers.forEach((c) => c.setAlpha(0.2));
|
||||
return;
|
||||
}
|
||||
this.diceContainers.forEach((c) => c.setAlpha(1));
|
||||
this.renderDieFace(0, this.gs.dice[0]);
|
||||
this.renderDieFace(1, this.gs.dice[1]);
|
||||
|
||||
// Dim used dice
|
||||
const used = [true, true];
|
||||
const remaining = [...this.gs.movesLeft];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const idx = remaining.indexOf(this.gs.dice[i]);
|
||||
if (idx !== -1) { used[i] = false; remaining.splice(idx, 1); }
|
||||
}
|
||||
this.diceContainers.forEach((c, i) => c.setAlpha(used[i] ? 0.3 : 1));
|
||||
}
|
||||
|
||||
// ── AI Turn ─────────────────────────────────────────────────────────────────
|
||||
|
||||
startAITurn() {
|
||||
if (this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'black') return;
|
||||
const opponentName = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.showTurnBanner(`${opponentName}'s Turn`);
|
||||
this.animating = true;
|
||||
this.updateButtonStates();
|
||||
|
||||
this.time.delayedCall(1000, () => {
|
||||
const finalState = rollDice(this.gs);
|
||||
const [d1, d2] = finalState.dice;
|
||||
this.animateDiceRoll([d1, d2], () => {
|
||||
this.gs = finalState;
|
||||
this.updateDiceDisplay();
|
||||
|
||||
if (d1 === d2) this.playOpponentEmotion('happy');
|
||||
|
||||
if (!hasAnyMove(this.gs)) {
|
||||
this.setStatus(`${opponentName} has no moves — turn passed`);
|
||||
this.time.delayedCall(1500, () => {
|
||||
this.gs = endTurn(this.gs);
|
||||
this.setStatus('');
|
||||
this.animating = false;
|
||||
this.renderAll();
|
||||
this.showTurnBanner('Your Turn');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const moves = chooseMoves(this.gs);
|
||||
this.executeAIMovesSequentially(moves, 0, () => {
|
||||
this.animating = false;
|
||||
if (this.gs.phase === 'game_over') {
|
||||
this.onGameOver();
|
||||
} else {
|
||||
this.renderAll();
|
||||
this.showTurnBanner('Your Turn');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
executeAIMovesSequentially(moves, index, onAllDone) {
|
||||
if (index >= moves.length || this.gs.phase === 'game_over') {
|
||||
onAllDone();
|
||||
return;
|
||||
}
|
||||
const move = moves[index];
|
||||
|
||||
const fromPos = move.from === 'bar'
|
||||
? this.barScreenPos('black')
|
||||
: this.checkerScreenPos(move.from, Math.max(0, this.gs.points[move.from].count - 1));
|
||||
|
||||
const toPos = move.to === 'off'
|
||||
? this.bearOffScreenPos('black')
|
||||
: this.checkerScreenPos(move.to, this.stackHeight(move.to));
|
||||
|
||||
// Spawn a temporary checker for animation
|
||||
const tempChecker = this.makeChecker('black', fromPos.x, fromPos.y);
|
||||
tempChecker.setDepth(DEPTH.movingChecker);
|
||||
|
||||
this.animateArc(tempChecker, fromPos, toPos, () => {
|
||||
tempChecker.destroy();
|
||||
this.gs = applyMove(this.gs, move);
|
||||
this.renderAll();
|
||||
|
||||
if (move.hit) this.playOpponentEmotion('happy');
|
||||
else if (move.to === 'off') this.playOpponentEmotion('happy');
|
||||
|
||||
this.time.delayedCall(350, () => {
|
||||
this.executeAIMovesSequentially(moves, index + 1, onAllDone);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Animations ──────────────────────────────────────────────────────────────
|
||||
|
||||
animateArc(container, from, to, onComplete) {
|
||||
if (!container) { onComplete(); return; }
|
||||
const midX = (from.x + to.x) / 2;
|
||||
const midY = Math.min(from.y, to.y) - 130;
|
||||
const prog = { t: 0 };
|
||||
this.tweens.add({
|
||||
targets: prog,
|
||||
t: 1,
|
||||
duration: 400,
|
||||
ease: 'Cubic.easeInOut',
|
||||
onUpdate: () => {
|
||||
const t = prog.t;
|
||||
const inv = 1 - t;
|
||||
container.x = inv * inv * from.x + 2 * inv * t * midX + t * t * to.x;
|
||||
container.y = inv * inv * from.y + 2 * inv * t * midY + t * t * to.y;
|
||||
},
|
||||
onComplete: () => {
|
||||
container.x = to.x;
|
||||
container.y = to.y;
|
||||
// Squash-and-stretch on landing
|
||||
this.tweens.add({ targets: container, scaleX: 1.3, scaleY: 0.7, duration: 60, yoyo: true, ease: 'Quad.easeOut', onComplete });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
animateSlide(container, to, duration) {
|
||||
this.tweens.add({
|
||||
targets: container,
|
||||
x: to.x, y: to.y,
|
||||
duration,
|
||||
ease: 'Quad.easeIn',
|
||||
});
|
||||
}
|
||||
|
||||
showTurnBanner(text) {
|
||||
const cx = BX + BW / 2;
|
||||
const banner = this.add.text(cx, BY - 80, text, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '36px',
|
||||
color: COLORS.textHex,
|
||||
backgroundColor: '#111923ee',
|
||||
padding: { x: 28, y: 12 },
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner);
|
||||
|
||||
this.tweens.add({
|
||||
targets: banner,
|
||||
y: BY - 22,
|
||||
duration: 320,
|
||||
ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
this.time.delayedCall(1200, () => {
|
||||
this.tweens.add({ targets: banner, y: BY - 80, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── UI Updates ───────────────────────────────────────────────────────────────
|
||||
|
||||
updateButtonStates() {
|
||||
const canRoll = !this.animating && this.gs.phase === 'roll' && this.gs.currentPlayer === 'white';
|
||||
this.rollBtn?.setEnabled(canRoll);
|
||||
this.turnText?.setText(this.gs.currentPlayer === 'white' ? 'Your Turn' : 'Opponent\'s Turn');
|
||||
}
|
||||
|
||||
updatePipLabels() {
|
||||
const wPip = computePipCount(this.gs, 'white');
|
||||
const bPip = computePipCount(this.gs, 'black');
|
||||
this.pipWhiteText?.setText(String(wPip));
|
||||
this.pipBlackText?.setText(String(bPip));
|
||||
}
|
||||
|
||||
setStatus(msg) {
|
||||
this.statusText?.setText(msg);
|
||||
}
|
||||
|
||||
// ── Win / Game Over ──────────────────────────────────────────────────────────
|
||||
|
||||
onGameOver() {
|
||||
const winner = this.gs.winner;
|
||||
const isHuman = winner === 'white';
|
||||
const opponentName = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.playOpponentEmotion(isHuman ? 'upset' : 'happy');
|
||||
|
||||
// Particle burst
|
||||
if (isHuman) {
|
||||
const emitter = this.add.particles(BX + BW / 2, BY + BH / 2, 'bgParticle', {
|
||||
speed: { min: 150, max: 500 },
|
||||
lifespan: 1400,
|
||||
scale: { start: 1.5, end: 0 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
quantity: 5,
|
||||
frequency: 25,
|
||||
tint: [C.gold, 0xffffff, COLORS.accent],
|
||||
angle: { min: 0, max: 360 },
|
||||
}).setDepth(DEPTH.banner);
|
||||
this.time.delayedCall(2000, () => emitter.destroy());
|
||||
}
|
||||
|
||||
this.time.delayedCall(500, () => {
|
||||
const msg = isHuman
|
||||
? '🎉 You Win!\nBear off all 15 checkers!'
|
||||
: `${opponentName} wins this time.\nBetter luck next game!`;
|
||||
|
||||
const overlay = this.add.rectangle(BX + BW / 2, BY + BH / 2, 700, 300, 0x0a0e14, 0.9)
|
||||
.setStrokeStyle(3, COLORS.accent)
|
||||
.setDepth(DEPTH.banner);
|
||||
const txt = this.add.text(BX + BW / 2, BY + BH / 2 - 40, msg, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '32px',
|
||||
color: isHuman ? '#ffd700' : COLORS.textHex,
|
||||
align: 'center',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||
|
||||
new Button(this, BX + BW / 2 - 90, BY + BH / 2 + 80, 'Play Again', () => {
|
||||
overlay.destroy(); txt.destroy(); this.initGame();
|
||||
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
|
||||
new Button(this, BX + BW / 2 + 90, BY + BH / 2 + 80, 'Leave', () => {
|
||||
this.scene.start('GameMenu');
|
||||
}, { variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
// Pure game logic — no Phaser dependency.
|
||||
// Convention: point indices 0–23 (0 = point 1, White's home ace-point).
|
||||
// White moves high→low (23→0), bears off past index 0.
|
||||
// Black moves low→high (0→23), bears off past index 23.
|
||||
// White home board: indices 0–5. Black home board: indices 18–23.
|
||||
|
||||
export function createInitialState() {
|
||||
const points = Array.from({ length: 24 }, () => ({ color: null, count: 0 }));
|
||||
points[23] = { color: 'white', count: 2 };
|
||||
points[12] = { color: 'white', count: 5 };
|
||||
points[7] = { color: 'white', count: 3 };
|
||||
points[5] = { color: 'white', count: 5 };
|
||||
points[0] = { color: 'black', count: 2 };
|
||||
points[11] = { color: 'black', count: 5 };
|
||||
points[16] = { color: 'black', count: 3 };
|
||||
points[18] = { color: 'black', count: 5 };
|
||||
return {
|
||||
points,
|
||||
bar: { white: 0, black: 0 },
|
||||
borneOff: { white: 0, black: 0 },
|
||||
dice: null,
|
||||
movesLeft: [],
|
||||
currentPlayer: 'white',
|
||||
phase: 'roll',
|
||||
winner: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneState(state) {
|
||||
return JSON.parse(JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function rollDice(state) {
|
||||
const d1 = Math.ceil(Math.random() * 6);
|
||||
const d2 = Math.ceil(Math.random() * 6);
|
||||
return rollSpecificDice(state, d1, d2);
|
||||
}
|
||||
|
||||
export function rollSpecificDice(state, d1, d2) {
|
||||
const s = cloneState(state);
|
||||
s.dice = [d1, d2];
|
||||
s.movesLeft = d1 === d2 ? [d1, d1, d1, d1] : [d1, d2];
|
||||
s.phase = 'move';
|
||||
return s;
|
||||
}
|
||||
|
||||
export function endTurn(state) {
|
||||
const s = cloneState(state);
|
||||
s.currentPlayer = s.currentPlayer === 'white' ? 'black' : 'white';
|
||||
s.dice = null;
|
||||
s.movesLeft = [];
|
||||
s.phase = 'roll';
|
||||
return s;
|
||||
}
|
||||
|
||||
export function allCheckersInHome(state, player) {
|
||||
if (state.bar[player] > 0) return false;
|
||||
const [homeMin, homeMax] = player === 'white' ? [0, 5] : [18, 23];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if (i < homeMin || i > homeMax) {
|
||||
if (state.points[i].color === player && state.points[i].count > 0) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Furthest checker from the bear-off edge (by index):
|
||||
// White: highest index in 0–5 (index 5 = 6 pips away)
|
||||
// Black: lowest index in 18–23 (index 18 = 6 pips away)
|
||||
function furthestHomeChecker(state, player) {
|
||||
const { points } = state;
|
||||
if (player === 'white') {
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
if (points[i].color === 'white' && points[i].count > 0) return i;
|
||||
}
|
||||
} else {
|
||||
for (let i = 18; i <= 23; i++) {
|
||||
if (points[i].color === 'black' && points[i].count > 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function getValidMoves(state) {
|
||||
const { currentPlayer: player, movesLeft, points, bar } = state;
|
||||
const opp = player === 'white' ? 'black' : 'white';
|
||||
const uniqueDice = [...new Set(movesLeft)];
|
||||
const inBearOff = allCheckersInHome(state, player);
|
||||
const moves = [];
|
||||
|
||||
const isBlocked = (idx) => points[idx]?.color === opp && points[idx].count >= 2;
|
||||
const isHit = (idx) => points[idx]?.color === opp && points[idx].count === 1;
|
||||
|
||||
// Bar entry takes priority — only these moves if bar has checkers
|
||||
if (bar[player] > 0) {
|
||||
for (const die of uniqueDice) {
|
||||
// White enters opposite side: die 1 → index 23, die 6 → index 18
|
||||
// Black enters near side: die 1 → index 0, die 6 → index 5
|
||||
const entryIdx = player === 'white' ? (24 - die) : (die - 1);
|
||||
if (!isBlocked(entryIdx)) {
|
||||
moves.push({ from: 'bar', to: entryIdx, dieUsed: die, hit: isHit(entryIdx) });
|
||||
}
|
||||
}
|
||||
return dedup(moves);
|
||||
}
|
||||
|
||||
for (const die of uniqueDice) {
|
||||
for (let i = 0; i < 24; i++) {
|
||||
if (points[i].color !== player || points[i].count === 0) continue;
|
||||
|
||||
if (player === 'white') {
|
||||
const dest = i - die;
|
||||
if (dest >= 0) {
|
||||
if (!isBlocked(dest)) moves.push({ from: i, to: dest, dieUsed: die, hit: isHit(dest) });
|
||||
} else if (inBearOff) {
|
||||
if (dest === -1) {
|
||||
// Exact bear-off (die == i + 1)
|
||||
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
|
||||
} else {
|
||||
// Overshoot — only the furthest checker may be borne off
|
||||
if (furthestHomeChecker(state, 'white') === i) {
|
||||
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const dest = i + die;
|
||||
if (dest <= 23) {
|
||||
if (!isBlocked(dest)) moves.push({ from: i, to: dest, dieUsed: die, hit: isHit(dest) });
|
||||
} else if (inBearOff) {
|
||||
if (dest === 24) {
|
||||
// Exact bear-off (die == 24 - i)
|
||||
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
|
||||
} else {
|
||||
// Overshoot
|
||||
if (furthestHomeChecker(state, 'black') === i) {
|
||||
moves.push({ from: i, to: 'off', dieUsed: die, hit: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedup(moves);
|
||||
}
|
||||
|
||||
function dedup(moves) {
|
||||
const seen = new Set();
|
||||
return moves.filter((m) => {
|
||||
const key = `${m.from}|${m.to}|${m.dieUsed}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyMove(state, move) {
|
||||
let s = cloneState(state);
|
||||
const player = s.currentPlayer;
|
||||
const opp = player === 'white' ? 'black' : 'white';
|
||||
|
||||
// Remove from source
|
||||
if (move.from === 'bar') {
|
||||
s.bar[player] = Math.max(0, s.bar[player] - 1);
|
||||
} else {
|
||||
s.points[move.from].count--;
|
||||
if (s.points[move.from].count === 0) s.points[move.from].color = null;
|
||||
}
|
||||
|
||||
// Handle hit — send opponent blot to bar
|
||||
if (move.hit && move.to !== 'off') {
|
||||
s.points[move.to].count = 0;
|
||||
s.points[move.to].color = null;
|
||||
s.bar[opp]++;
|
||||
}
|
||||
|
||||
// Place on destination
|
||||
if (move.to === 'off') {
|
||||
s.borneOff[player]++;
|
||||
} else {
|
||||
s.points[move.to].color = player;
|
||||
s.points[move.to].count++;
|
||||
}
|
||||
|
||||
// Consume the die
|
||||
const dieIdx = s.movesLeft.indexOf(move.dieUsed);
|
||||
if (dieIdx !== -1) s.movesLeft.splice(dieIdx, 1);
|
||||
|
||||
// Win check
|
||||
if (s.borneOff[player] >= 15) {
|
||||
s.winner = player;
|
||||
s.phase = 'game_over';
|
||||
return s;
|
||||
}
|
||||
|
||||
// Auto-end turn if no dice remain or no legal moves remain
|
||||
if (s.movesLeft.length === 0 || getValidMoves(s).length === 0) {
|
||||
s = endTurn(s);
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
export function hasAnyMove(state) {
|
||||
return getValidMoves(state).length > 0;
|
||||
}
|
||||
|
||||
export function computePipCount(state, player) {
|
||||
let pips = 0;
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const pt = state.points[i];
|
||||
if (pt.color !== player || pt.count === 0) continue;
|
||||
const dist = player === 'white' ? (i + 1) : (24 - i);
|
||||
pips += dist * pt.count;
|
||||
}
|
||||
pips += state.bar[player] * 25;
|
||||
return pips;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
export const SUITS = ['s', 'h', 'd', 'c']; // spades, hearts, diamonds, clubs
|
||||
export const RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', 'T', 'J', 'Q', 'K', 'A'];
|
||||
|
||||
// Rank value for comparison (2=2 … A=14)
|
||||
const RANK_VALUE = Object.fromEntries(RANKS.map((r, i) => [r, i + 2]));
|
||||
|
||||
export class Card {
|
||||
constructor(rank, suit) {
|
||||
this.rank = rank;
|
||||
this.suit = suit;
|
||||
this.value = RANK_VALUE[rank]; // 2–14
|
||||
this.key = `${rank}${suit}`; // e.g. 'As', 'Td', '2h'
|
||||
}
|
||||
|
||||
/** Display label shown on the card face (rank only; suit shown separately) */
|
||||
get label() {
|
||||
return this.rank === 'T' ? '10' : this.rank;
|
||||
}
|
||||
|
||||
/** True for heart / diamond */
|
||||
get isRed() {
|
||||
return this.suit === 'h' || this.suit === 'd';
|
||||
}
|
||||
|
||||
/** Unicode suit symbol */
|
||||
get suitSymbol() {
|
||||
return { s: '♠', h: '♥', d: '♦', c: '♣' }[this.suit];
|
||||
}
|
||||
}
|
||||
|
||||
export class Deck {
|
||||
constructor() {
|
||||
this.cards = [];
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/** Rebuild full 52-card deck in order. */
|
||||
reset() {
|
||||
this.cards = [];
|
||||
for (const suit of SUITS) {
|
||||
for (const rank of RANKS) {
|
||||
this.cards.push(new Card(rank, suit));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fisher-Yates in-place shuffle. */
|
||||
shuffle() {
|
||||
const a = this.cards;
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove and return n cards from the top of the deck. */
|
||||
deal(n = 1) {
|
||||
if (n > this.cards.length) throw new Error('Not enough cards in deck.');
|
||||
return this.cards.splice(0, n);
|
||||
}
|
||||
|
||||
get remaining() {
|
||||
return this.cards.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { evaluateHand, callAmount, canCheck, minRaise } from './HoldemLogic.js';
|
||||
|
||||
// Per-seat personalities indexed by seat number (1–3 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
|
||||
};
|
||||
|
||||
// Chen formula approximation: 0–20 score for pre-flop hand strength
|
||||
function chenScore(hand) {
|
||||
const [a, b] = [...hand].sort((x, y) => y.value - x.value);
|
||||
const hi = a.value;
|
||||
let score = hi >= 14 ? 10 : hi >= 13 ? 8 : hi >= 12 ? 7 : hi >= 11 ? 6 : hi / 2;
|
||||
|
||||
if (a.value === b.value) {
|
||||
score = Math.max(score * 2, 5);
|
||||
} else {
|
||||
const gap = a.value - b.value - 1;
|
||||
if (gap === 0) score += 1; // connected
|
||||
else if (gap === 1) score -= 1; // 1-gap
|
||||
else if (gap === 2) score -= 2; // 2-gap
|
||||
else if (gap === 3) score -= 4; // 3-gap
|
||||
else score -= 5; // bigger gap
|
||||
|
||||
if (b.value >= 2 && b.value <= 7 && gap <= 1) score += 1; // straight bonus
|
||||
|
||||
if (a.suit === b.suit) score += 2; // suited bonus
|
||||
}
|
||||
return Math.max(0, score);
|
||||
}
|
||||
|
||||
// Post-flop: rough hand percentile (0–1) from evaluation rank + board texture
|
||||
function handStrength(player, community) {
|
||||
if (community.length === 0) return null;
|
||||
const result = evaluateHand([...player.hand, ...community]);
|
||||
// rank 0–8; normalize to 0–1 with exponential curve
|
||||
return Math.min(1, (result.rank + 1) / 9 + result.tiebreakers[0] / 120);
|
||||
}
|
||||
|
||||
// Pot odds: ratio of call amount to total pot after calling
|
||||
function potOdds(state, seat) {
|
||||
const toCall = callAmount(state, seat);
|
||||
if (toCall === 0) return 1; // free to check
|
||||
return toCall / (state.pot + toCall);
|
||||
}
|
||||
|
||||
export function chooseAction(state, seat) {
|
||||
const player = state.players[seat];
|
||||
const pers = PERSONALITIES[seat] ?? PERSONALITIES[1];
|
||||
const isCheck = canCheck(state, seat);
|
||||
const toCall = callAmount(state, seat);
|
||||
const odds = potOdds(state, seat);
|
||||
|
||||
// Determine strength metric
|
||||
let strength;
|
||||
if (state.phase === 'preflop') {
|
||||
const chen = chenScore(player.hand);
|
||||
strength = chen / 20; // normalize to 0–1
|
||||
} else {
|
||||
strength = handStrength(player, state.community) ?? 0.5;
|
||||
}
|
||||
|
||||
// Bluffing: occasionally treat weak hand as strong
|
||||
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;
|
||||
|
||||
if (isCheck) {
|
||||
// Check or bet
|
||||
if (strength > 0.55 && Math.random() < pers.raiseFactor) {
|
||||
const betAmount = computeRaiseAmount(state, seat, player, strength, pers);
|
||||
return { type: 'raise', amount: betAmount };
|
||||
}
|
||||
return { type: 'check' };
|
||||
}
|
||||
|
||||
// There's a bet to call
|
||||
if (strength < foldThreshold && !isCheck) {
|
||||
return { type: 'fold' };
|
||||
}
|
||||
|
||||
// Strong enough to continue — raise or call?
|
||||
if (strength > 0.62 && Math.random() < pers.raiseFactor) {
|
||||
if (player.chips <= toCall * 1.5) {
|
||||
return { type: 'allin' };
|
||||
}
|
||||
const betAmount = computeRaiseAmount(state, seat, player, strength, pers);
|
||||
return { type: 'raise', amount: betAmount };
|
||||
}
|
||||
|
||||
// Go all-in if call would leave almost nothing
|
||||
if (toCall >= player.chips) return { type: 'allin' };
|
||||
|
||||
return { type: 'call' };
|
||||
}
|
||||
|
||||
function computeRaiseAmount(state, seat, player, strength, pers) {
|
||||
const base = minRaise(state);
|
||||
// Scale raise size with hand strength and personality
|
||||
const multiplier = 1 + strength * pers.raiseFactor * 3;
|
||||
const raw = Math.round(base * multiplier / 5) * 5; // round to $5
|
||||
return Math.min(raw, player.chips + (state.players[seat].bet));
|
||||
}
|
||||
|
|
@ -0,0 +1,570 @@
|
|||
import { Deck } from '../cards/Deck.js';
|
||||
|
||||
// ── Blind schedule ────────────────────────────────────────────────────────────
|
||||
export const BLIND_LEVELS = [
|
||||
{ level: 1, small: 5, big: 10 },
|
||||
{ level: 2, small: 10, big: 20 },
|
||||
{ level: 3, small: 15, big: 30 },
|
||||
{ level: 4, small: 25, big: 50 },
|
||||
{ level: 5, small: 50, big: 100 },
|
||||
{ level: 6, small: 75, big: 150 },
|
||||
{ level: 7, small: 100, big: 200 },
|
||||
{ level: 8, small: 150, big: 300 },
|
||||
{ level: 9, small: 250, big: 500 },
|
||||
{ level: 10, small: 400, big: 800 },
|
||||
];
|
||||
|
||||
const LEVEL_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export function getBlindLevel(elapsedMs) {
|
||||
const idx = Math.min(
|
||||
Math.floor(elapsedMs / LEVEL_INTERVAL_MS),
|
||||
BLIND_LEVELS.length - 1,
|
||||
);
|
||||
return BLIND_LEVELS[idx];
|
||||
}
|
||||
|
||||
// ── Hand evaluation ───────────────────────────────────────────────────────────
|
||||
// Returns { rank: 0–8, name, tiebreakers: number[] }
|
||||
// rank 0=high card … 8=straight flush
|
||||
export function evaluateHand(cards) {
|
||||
if (cards.length <= 5) return evaluate5(cards);
|
||||
return bestFiveFrom(cards);
|
||||
}
|
||||
|
||||
// Picks the best 5-card hand from any n≥6 card set via true C(n,5) enumeration.
|
||||
function bestFiveFrom(cards) {
|
||||
const n = cards.length;
|
||||
let best = null;
|
||||
for (let a = 0; a < n - 4; a++)
|
||||
for (let b = a + 1; b < n - 3; b++)
|
||||
for (let c = b + 1; c < n - 2; c++)
|
||||
for (let d = c + 1; d < n - 1; d++)
|
||||
for (let e = d + 1; e < n; e++) {
|
||||
const result = evaluate5([cards[a], cards[b], cards[c], cards[d], cards[e]]);
|
||||
if (!best || compareHands(result, best) > 0) best = result;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function evaluate5(cards) {
|
||||
const values = cards.map((c) => c.value).sort((a, b) => b - a);
|
||||
const suits = cards.map((c) => c.suit);
|
||||
const ranks = cards.map((c) => c.rank);
|
||||
|
||||
const isFlush = suits.every((s) => s === suits[0]);
|
||||
const isStraight = checkStraight(values);
|
||||
const counts = countValues(values);
|
||||
const groups = buildGroups(counts); // sorted by [count desc, value desc]
|
||||
|
||||
if (isFlush && isStraight) {
|
||||
const high = isStraight === 'wheel' ? 5 : values[0];
|
||||
return { rank: 8, name: high === 14 ? 'Royal Flush' : 'Straight Flush', tiebreakers: [high] };
|
||||
}
|
||||
if (groups[0][0] === 4) {
|
||||
return { rank: 7, name: 'Four of a Kind', tiebreakers: [groups[0][1], groups[1][1]] };
|
||||
}
|
||||
if (groups[0][0] === 3 && groups[1][0] === 2) {
|
||||
return { rank: 6, name: 'Full House', tiebreakers: [groups[0][1], groups[1][1]] };
|
||||
}
|
||||
if (isFlush) {
|
||||
return { rank: 5, name: 'Flush', tiebreakers: values };
|
||||
}
|
||||
if (isStraight) {
|
||||
const high = isStraight === 'wheel' ? 5 : values[0];
|
||||
return { rank: 4, name: 'Straight', tiebreakers: [high] };
|
||||
}
|
||||
if (groups[0][0] === 3) {
|
||||
return { rank: 3, name: 'Three of a Kind', tiebreakers: [groups[0][1], ...groups.slice(1).map((g) => g[1])] };
|
||||
}
|
||||
if (groups[0][0] === 2 && groups[1][0] === 2) {
|
||||
const [p1, p2, kick] = groups;
|
||||
return { rank: 2, name: 'Two Pair', tiebreakers: [p1[1], p2[1], kick[1]] };
|
||||
}
|
||||
if (groups[0][0] === 2) {
|
||||
return { rank: 1, name: 'Pair', tiebreakers: [groups[0][1], ...groups.slice(1).map((g) => g[1])] };
|
||||
}
|
||||
return { rank: 0, name: 'High Card', tiebreakers: values };
|
||||
}
|
||||
|
||||
function checkStraight(sortedValues) {
|
||||
// Check wheel (A-2-3-4-5) by treating Ace as 1
|
||||
const vals = [...new Set(sortedValues)];
|
||||
if (vals.length < 5) return false;
|
||||
|
||||
// Standard straight
|
||||
if (vals[0] - vals[4] === 4 && vals.length === 5) return true;
|
||||
|
||||
// Wheel: A-2-3-4-5 → sorted values [14,5,4,3,2]
|
||||
if (vals[0] === 14 && vals[1] === 5 && vals[2] === 4 && vals[3] === 3 && vals[4] === 2) {
|
||||
return 'wheel';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function countValues(values) {
|
||||
const map = {};
|
||||
for (const v of values) map[v] = (map[v] ?? 0) + 1;
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildGroups(counts) {
|
||||
return Object.entries(counts)
|
||||
.map(([v, c]) => [c, Number(v)])
|
||||
.sort((a, b) => b[0] - a[0] || b[1] - a[1]);
|
||||
}
|
||||
|
||||
// Returns positive if a beats b, negative if b beats a, 0 if tie
|
||||
export function compareHands(a, b) {
|
||||
if (a.rank !== b.rank) return a.rank - b.rank;
|
||||
for (let i = 0; i < Math.max(a.tiebreakers.length, b.tiebreakers.length); i++) {
|
||||
const diff = (a.tiebreakers[i] ?? 0) - (b.tiebreakers[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Pot building (side pots) ──────────────────────────────────────────────────
|
||||
// Builds pots from p.totalBet (cumulative hand contribution), safe to call
|
||||
// after bets have been swept into state.pot by collectBets.
|
||||
// Returns array of { amount, eligibleSeats[] } ordered smallest to largest.
|
||||
function buildPotsFromTotals(allPlayers) {
|
||||
const live = allPlayers.filter((p) => !p.eliminated && p.totalBet > 0);
|
||||
if (live.length === 0) return [];
|
||||
|
||||
const allInLevels = [...new Set(
|
||||
live.filter((p) => p.allIn && !p.folded).map((p) => p.totalBet),
|
||||
)].sort((a, b) => a - b);
|
||||
|
||||
const pots = [];
|
||||
let prev = 0;
|
||||
|
||||
const slice = (cap) => {
|
||||
let amount = 0;
|
||||
const eligible = [];
|
||||
for (const p of live) {
|
||||
const contrib = Math.min(p.totalBet, cap) - Math.min(p.totalBet, prev);
|
||||
if (contrib <= 0) continue;
|
||||
amount += contrib;
|
||||
if (!p.folded) eligible.push(p.seat);
|
||||
}
|
||||
if (amount > 0) pots.push({ amount, eligibleSeats: eligible });
|
||||
prev = cap;
|
||||
};
|
||||
|
||||
for (const level of allInLevels) slice(level);
|
||||
|
||||
// Main pot — uncapped remainder
|
||||
let mainAmount = 0;
|
||||
const mainEligible = [];
|
||||
for (const p of live) {
|
||||
const contrib = p.totalBet - prev;
|
||||
if (contrib <= 0) continue;
|
||||
mainAmount += contrib;
|
||||
if (!p.folded) mainEligible.push(p.seat);
|
||||
}
|
||||
if (mainAmount > 0) pots.push({ amount: mainAmount, eligibleSeats: mainEligible });
|
||||
|
||||
return pots;
|
||||
}
|
||||
|
||||
// Legacy helper used for UI side-pot display during active betting rounds.
|
||||
// Uses current-round p.bet values (only valid before collectBets is called).
|
||||
export function buildPots(players) {
|
||||
const active = players.filter((p) => !p.folded && p.bet > 0);
|
||||
if (active.length === 0) return [];
|
||||
const allInAmounts = [...new Set(
|
||||
active.filter((p) => p.allIn).map((p) => p.bet),
|
||||
)].sort((a, b) => a - b);
|
||||
const pots = [];
|
||||
let prev = 0;
|
||||
const slice = (cap) => {
|
||||
let amount = 0;
|
||||
const eligible = [];
|
||||
for (const p of players) {
|
||||
if (p.folded) { amount += Math.min(p.bet, cap) - Math.min(p.bet, prev); }
|
||||
else {
|
||||
const contrib = Math.min(p.bet, cap) - Math.min(p.bet, prev);
|
||||
if (contrib > 0) { amount += contrib; eligible.push(p.seat); }
|
||||
}
|
||||
}
|
||||
if (amount > 0) pots.push({ amount, eligibleSeats: eligible });
|
||||
prev = cap;
|
||||
};
|
||||
for (const level of allInAmounts) slice(level);
|
||||
const mainEligible = players.filter((p) => !p.folded && p.bet > prev).map((p) => p.seat);
|
||||
let mainAmount = 0;
|
||||
for (const p of players) mainAmount += Math.max(0, p.bet - prev);
|
||||
if (mainAmount > 0) pots.push({ amount: mainAmount, eligibleSeats: mainEligible });
|
||||
return pots;
|
||||
}
|
||||
|
||||
// ── Showdown resolution ───────────────────────────────────────────────────────
|
||||
// allPlayers: full players array including folded (needed for side-pot math).
|
||||
// Returns Map<seat, chipsWon>
|
||||
export function resolveShowdown(allPlayers, community) {
|
||||
const pots = buildPotsFromTotals(allPlayers);
|
||||
const winnings = new Map(allPlayers.map((p) => [p.seat, 0]));
|
||||
|
||||
for (const pot of pots) {
|
||||
if (pot.eligibleSeats.length === 0) continue;
|
||||
|
||||
const contenders = pot.eligibleSeats.map((seat) => {
|
||||
const p = allPlayers.find((pl) => pl.seat === seat);
|
||||
return { seat, eval: evaluateHand([...p.hand, ...community]) };
|
||||
});
|
||||
|
||||
let best = contenders[0].eval;
|
||||
for (const c of contenders) {
|
||||
if (compareHands(c.eval, best) > 0) best = c.eval;
|
||||
}
|
||||
const winners = contenders.filter((c) => compareHands(c.eval, best) === 0);
|
||||
|
||||
const share = Math.floor(pot.amount / winners.length);
|
||||
const remainder = pot.amount - share * winners.length;
|
||||
const sorted = [...winners].sort((a, b) => a.seat - b.seat);
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
winnings.set(sorted[i].seat, (winnings.get(sorted[i].seat) ?? 0) + share + (i === 0 ? remainder : 0));
|
||||
}
|
||||
}
|
||||
|
||||
return winnings;
|
||||
}
|
||||
|
||||
// ── Initial state ─────────────────────────────────────────────────────────────
|
||||
export function createInitialState(opponentDefs, buyIn) {
|
||||
const players = [
|
||||
{
|
||||
seat: 0,
|
||||
name: 'You',
|
||||
chips: buyIn,
|
||||
hand: [],
|
||||
folded: false,
|
||||
allIn: false,
|
||||
bet: 0,
|
||||
totalBet: 0,
|
||||
isHuman: true,
|
||||
isDealer: false,
|
||||
eliminated: false,
|
||||
hasActedThisRound: false,
|
||||
},
|
||||
...opponentDefs.slice(0, 3).map((opp, i) => ({
|
||||
seat: i + 1,
|
||||
name: opp.name,
|
||||
chips: buyIn,
|
||||
hand: [],
|
||||
folded: false,
|
||||
allIn: false,
|
||||
bet: 0,
|
||||
totalBet: 0,
|
||||
isHuman: false,
|
||||
isDealer: false,
|
||||
eliminated: false,
|
||||
hasActedThisRound: false,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
phase: 'waiting',
|
||||
deck: null,
|
||||
players,
|
||||
community: [],
|
||||
pot: 0,
|
||||
sidePots: [],
|
||||
dealerSeat: 0,
|
||||
actionSeat: -1,
|
||||
roundBet: 0,
|
||||
lastRaiser: null,
|
||||
gameStartMs: Date.now(),
|
||||
handNumber: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hand setup ────────────────────────────────────────────────────────────────
|
||||
export function startHand(state) {
|
||||
const activePlayers = state.players.filter((p) => !p.eliminated);
|
||||
if (activePlayers.length < 2) return state; // game over
|
||||
|
||||
// Advance dealer
|
||||
let dealerSeat = state.dealerSeat;
|
||||
for (let i = 0; i < state.players.length; i++) {
|
||||
dealerSeat = (dealerSeat + 1) % state.players.length;
|
||||
if (!state.players[dealerSeat].eliminated) break;
|
||||
}
|
||||
|
||||
// Reset all hands
|
||||
const players = state.players.map((p) => ({
|
||||
...p,
|
||||
hand: [],
|
||||
folded: p.eliminated,
|
||||
allIn: false,
|
||||
bet: 0,
|
||||
totalBet: 0,
|
||||
isDealer: p.seat === dealerSeat,
|
||||
hasActedThisRound: false,
|
||||
}));
|
||||
|
||||
// Deal 2 cards each
|
||||
const deck = new Deck();
|
||||
deck.shuffle();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
for (const p of players) {
|
||||
if (!p.eliminated) p.hand.push(deck.deal(1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Post blinds
|
||||
const blind = getBlindLevel(Date.now() - state.gameStartMs);
|
||||
const { small: sb, big: bb } = blind;
|
||||
const seatOrder = getSeatOrder(players, dealerSeat);
|
||||
const sbSeat = seatOrder[0];
|
||||
const bbSeat = seatOrder[1];
|
||||
|
||||
for (const p of players) {
|
||||
if (p.seat === sbSeat) {
|
||||
const amount = Math.min(p.chips, sb);
|
||||
p.chips -= amount;
|
||||
p.bet = amount;
|
||||
p.totalBet = amount;
|
||||
if (p.chips === 0) p.allIn = true;
|
||||
}
|
||||
if (p.seat === bbSeat) {
|
||||
const amount = Math.min(p.chips, bb);
|
||||
p.chips -= amount;
|
||||
p.bet = amount;
|
||||
p.totalBet = amount;
|
||||
if (p.chips === 0) p.allIn = true;
|
||||
}
|
||||
}
|
||||
|
||||
// First to act pre-flop is after big blind
|
||||
let actionSeat = seatOrder[2] ?? seatOrder[0];
|
||||
|
||||
return {
|
||||
...state,
|
||||
phase: 'preflop',
|
||||
deck,
|
||||
players,
|
||||
community: [],
|
||||
pot: 0,
|
||||
sidePots: [],
|
||||
dealerSeat,
|
||||
actionSeat,
|
||||
roundBet: bb,
|
||||
lastRaiser: bbSeat,
|
||||
handNumber: state.handNumber + 1,
|
||||
sbSeat,
|
||||
bbSeat,
|
||||
blind,
|
||||
};
|
||||
}
|
||||
|
||||
// Returns active (not eliminated, not folded) seat numbers starting after dealer
|
||||
function getSeatOrder(players, dealerSeat) {
|
||||
const n = players.length;
|
||||
const order = [];
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const seat = (dealerSeat + i) % n;
|
||||
if (!players[seat].eliminated) order.push(seat);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// ── Betting actions ───────────────────────────────────────────────────────────
|
||||
export function applyAction(state, seat, action) {
|
||||
if (state.actionSeat !== seat) return state;
|
||||
|
||||
const players = state.players.map((p) => ({ ...p }));
|
||||
const player = players[seat];
|
||||
let { pot, roundBet, lastRaiser } = state;
|
||||
|
||||
switch (action.type) {
|
||||
case 'fold':
|
||||
player.folded = true;
|
||||
player.hasActedThisRound = true;
|
||||
break;
|
||||
|
||||
case 'check':
|
||||
if (player.bet < roundBet) return state;
|
||||
player.hasActedThisRound = true;
|
||||
break;
|
||||
|
||||
case 'call': {
|
||||
const toCall = Math.min(roundBet - player.bet, player.chips);
|
||||
player.chips -= toCall;
|
||||
player.bet += toCall;
|
||||
player.totalBet += toCall;
|
||||
if (player.chips === 0) player.allIn = true;
|
||||
player.hasActedThisRound = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'raise': {
|
||||
const minR = roundBet === 0 ? (state.blind?.big ?? 10) : roundBet * 2;
|
||||
const raiseTotal = Math.max(minR, action.amount ?? minR);
|
||||
const toAdd = Math.min(raiseTotal - player.bet, player.chips);
|
||||
player.chips -= toAdd;
|
||||
player.bet += toAdd;
|
||||
player.totalBet += toAdd;
|
||||
if (player.chips === 0) player.allIn = true;
|
||||
roundBet = player.bet;
|
||||
lastRaiser = seat;
|
||||
for (const p of players) {
|
||||
if (p.seat !== seat && !p.eliminated && !p.folded && !p.allIn) {
|
||||
p.hasActedThisRound = false;
|
||||
}
|
||||
}
|
||||
player.hasActedThisRound = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'allin': {
|
||||
const toAdd = player.chips;
|
||||
player.chips = 0;
|
||||
player.bet += toAdd;
|
||||
player.totalBet += toAdd;
|
||||
player.allIn = true;
|
||||
if (player.bet > roundBet) {
|
||||
roundBet = player.bet;
|
||||
lastRaiser = seat;
|
||||
for (const p of players) {
|
||||
if (p.seat !== seat && !p.eliminated && !p.folded && !p.allIn) {
|
||||
p.hasActedThisRound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
player.hasActedThisRound = true;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextSeat = nextToAct(players, seat, roundBet);
|
||||
|
||||
if (nextSeat === null) {
|
||||
// Betting round over — collect bets into pot and advance phase
|
||||
return advancePhase({ ...state, players, pot, roundBet, lastRaiser });
|
||||
}
|
||||
|
||||
return { ...state, players, pot, roundBet, lastRaiser, actionSeat: nextSeat };
|
||||
}
|
||||
|
||||
function nextToAct(players, currentSeat, roundBet) {
|
||||
const n = players.length;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const seat = (currentSeat + i) % n;
|
||||
const p = players[seat];
|
||||
if (p.eliminated || p.folded || p.allIn) continue;
|
||||
if (p.bet < roundBet || !p.hasActedThisRound) return seat;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectBets(players, pot) {
|
||||
let total = pot;
|
||||
const updated = players.map((p) => {
|
||||
total += p.bet;
|
||||
return { ...p, bet: 0, hasActedThisRound: false };
|
||||
});
|
||||
return { players: updated, pot: total };
|
||||
}
|
||||
|
||||
function advancePhase(state) {
|
||||
const { players, deck, community } = state;
|
||||
|
||||
// Check if only one player remains (everyone else folded)
|
||||
const remaining = players.filter((p) => !p.eliminated && !p.folded);
|
||||
const { players: clearedPlayers, pot } = collectBets(players, state.pot);
|
||||
|
||||
if (remaining.length === 1) {
|
||||
// Award pot to last player standing
|
||||
const winner = clearedPlayers.find((p) => p.seat === remaining[0].seat);
|
||||
winner.chips += pot;
|
||||
return endHand({ ...state, players: clearedPlayers, pot: 0, phase: 'showdown', community });
|
||||
}
|
||||
|
||||
const nextPhase = { preflop: 'flop', flop: 'turn', turn: 'river', river: 'showdown' }[state.phase];
|
||||
|
||||
let newCommunity = [...community];
|
||||
if (nextPhase === 'flop') newCommunity = [...newCommunity, ...deck.deal(3)];
|
||||
if (nextPhase === 'turn') newCommunity = [...newCommunity, ...deck.deal(1)];
|
||||
if (nextPhase === 'river') newCommunity = [...newCommunity, ...deck.deal(1)];
|
||||
|
||||
if (nextPhase === 'showdown') {
|
||||
return endHand({ ...state, players: clearedPlayers, pot, community: newCommunity, phase: 'showdown' });
|
||||
}
|
||||
|
||||
// Reset bets for new round; first to act is first active after dealer
|
||||
const seatOrder = getSeatOrder(clearedPlayers, state.dealerSeat);
|
||||
const actionSeat = seatOrder[0];
|
||||
|
||||
return {
|
||||
...state,
|
||||
phase: nextPhase,
|
||||
players: clearedPlayers,
|
||||
pot,
|
||||
community: newCommunity,
|
||||
roundBet: 0,
|
||||
lastRaiser: null,
|
||||
actionSeat,
|
||||
sidePots: buildPots(clearedPlayers),
|
||||
};
|
||||
}
|
||||
|
||||
function endHand(state) {
|
||||
const { players, community } = state;
|
||||
let updatedPlayers = players.map((p) => ({ ...p }));
|
||||
|
||||
if (state.phase === 'showdown' && state.pot > 0) {
|
||||
const active = updatedPlayers.filter((p) => !p.eliminated && !p.folded);
|
||||
if (active.length === 1) {
|
||||
// Everyone else folded — sole survivor takes the pot
|
||||
updatedPlayers[active[0].seat].chips += state.pot;
|
||||
} else if (active.length > 1) {
|
||||
// Full showdown — use totalBet to build side pots and evaluate hands
|
||||
const winnings = resolveShowdown(updatedPlayers, community);
|
||||
for (const [seat, amount] of winnings) {
|
||||
updatedPlayers[seat].chips += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eliminate players at 0 chips
|
||||
for (const p of updatedPlayers) {
|
||||
if (p.chips === 0 && !p.eliminated) p.eliminated = true;
|
||||
}
|
||||
|
||||
const stillIn = updatedPlayers.filter((p) => !p.eliminated);
|
||||
const gameOver = stillIn.length <= 1;
|
||||
|
||||
return {
|
||||
...state,
|
||||
players: updatedPlayers,
|
||||
pot: 0,
|
||||
sidePots: [],
|
||||
phase: gameOver ? 'game_over' : 'between_hands',
|
||||
winner: gameOver ? stillIn[0]?.seat ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Query helpers ─────────────────────────────────────────────────────────────
|
||||
export function getActivePlayers(state) {
|
||||
return state.players.filter((p) => !p.eliminated && !p.folded);
|
||||
}
|
||||
|
||||
export function canCheck(state, seat) {
|
||||
const p = state.players[seat];
|
||||
return p && !p.folded && p.bet >= state.roundBet;
|
||||
}
|
||||
|
||||
export function callAmount(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (!p) return 0;
|
||||
return Math.min(state.roundBet - p.bet, p.chips);
|
||||
}
|
||||
|
||||
export function minRaise(state) {
|
||||
return state.roundBet === 0 ? (state.blind?.big ?? 10) : state.roundBet * 2;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from './config.js';
|
||||
import BootScene from './scenes/BootScene.js';
|
||||
import PreloadScene from './scenes/PreloadScene.js';
|
||||
|
|
@ -8,8 +8,11 @@ import RegisterScene from './scenes/RegisterScene.js';
|
|||
import VerifyScene from './scenes/VerifyScene.js';
|
||||
import ProfileScene from './scenes/ProfileScene.js';
|
||||
import GameMenuScene from './scenes/GameMenuScene.js';
|
||||
import OpponentSelectScene from './scenes/OpponentSelectScene.js';
|
||||
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';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -31,8 +34,11 @@ const config = {
|
|||
VerifyScene,
|
||||
ProfileScene,
|
||||
GameMenuScene,
|
||||
OpponentSelectScene,
|
||||
LobbyScene,
|
||||
GameRoomScene,
|
||||
BackgammonGame,
|
||||
HoldemGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
export default class BootScene extends Phaser.Scene {
|
||||
constructor() { super('Boot'); }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
|
|
@ -55,14 +55,11 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
openGame(game) {
|
||||
if (!auth.user && game.supportsMultiplayer) {
|
||||
this.scene.start('Login');
|
||||
return;
|
||||
}
|
||||
if (game.supportsMultiplayer) {
|
||||
if (game.multiplayerOnly) {
|
||||
if (!auth.user) { this.scene.start('Login'); return; }
|
||||
this.scene.start('Lobby', { game });
|
||||
} else {
|
||||
this.scene.start('GameRoom', { game });
|
||||
this.scene.start('OpponentSelect', { game });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { getSocket } from '../services/socket.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
|
@ -12,9 +12,22 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
init(data) {
|
||||
this.game = data.game;
|
||||
this.room = data.room ?? null;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.cardBack = data.cardBack ?? null;
|
||||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
opponents: this.opponents,
|
||||
playfield: this.playfield,
|
||||
cardBack: this.cardBack,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 80, `${this.game.name}`, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { connectSocket, getSocket } from '../services/socket.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,383 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
||||
// Option tile dimensions — playfield rows
|
||||
const TILE_W = 190;
|
||||
const TILE_H = 90;
|
||||
const TILE_GAP = 16;
|
||||
|
||||
// Card-back tile dimensions — sized to preserve 320:420 card aspect ratio
|
||||
// thumbW = CARD_TILE_W - 16 = 84; thumbH = 84 * (420/320) ≈ 110; CARD_TILE_H = 110 + 30 = 140
|
||||
const CARD_TILE_W = 100;
|
||||
const CARD_TILE_H = 141;
|
||||
const CARD_TILE_GAP = 14;
|
||||
|
||||
// Opponent grid scroll area
|
||||
const OPP_SCROLL_W = 1780;
|
||||
const OPP_SCROLL_H = 440;
|
||||
const OPP_SCROLL_TOP = 155; // top edge of scroll area
|
||||
|
||||
export default class OpponentSelectScene extends Phaser.Scene {
|
||||
constructor() { super('OpponentSelect'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.selected = new Set();
|
||||
this.cards = []; // [{ opp, el }]
|
||||
this.selectedPlayfield = null;
|
||||
this.playfieldTiles = [];
|
||||
this.selectedCardBack = null;
|
||||
this.cardBackTiles = [];
|
||||
}
|
||||
|
||||
async create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 60, this.gameDef.name, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(cx, 122, 'Choose your opponent', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '36px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
let opponents = [];
|
||||
try {
|
||||
const res = await fetch('/data/opponents.json');
|
||||
const json = await res.json();
|
||||
opponents = json.opponents ?? [];
|
||||
} catch {
|
||||
this.add.text(cx, GAME_HEIGHT / 2, 'Failed to load opponents.', {
|
||||
fontSize: '28px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5);
|
||||
return;
|
||||
}
|
||||
|
||||
this.startBtn = new Button(this, cx, 1048, 'Start Game', () => this.startGame(), { width: 280 });
|
||||
this.startBtn.setEnabled(false);
|
||||
|
||||
new Button(this, cx, 978, 'Back', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost',
|
||||
width: 280,
|
||||
});
|
||||
|
||||
this.buildOpponentGrid(opponents);
|
||||
|
||||
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
|
||||
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
|
||||
|
||||
if (this.gameDef.cardGame) {
|
||||
this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [],
|
||||
'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb),
|
||||
CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP);
|
||||
}
|
||||
|
||||
// Apply defaults
|
||||
const pfd = this.cache.json.get('playfields') ?? {};
|
||||
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
|
||||
if (this.gameDef.cardGame) {
|
||||
const cbd = this.cache.json.get('card-backs') ?? {};
|
||||
this.applyDefault('card-backs', cbd.default, 'selectedCardBack', 'cardBackTiles');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Opponent grid (DOM-based, 2-column scrollable list) ────────────────────
|
||||
|
||||
buildOpponentGrid(opponents) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const CARD_H = 118;
|
||||
const GAP = 16;
|
||||
|
||||
// Outer scroll container — just height + overflow, no layout opinions
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = [
|
||||
`width:${OPP_SCROLL_W}px`,
|
||||
`height:${OPP_SCROLL_H}px`,
|
||||
'overflow-y:auto',
|
||||
'overflow-x:hidden',
|
||||
'display:flex',
|
||||
'flex-direction:column',
|
||||
`gap:${GAP}px`,
|
||||
'padding:2px',
|
||||
'box-sizing:border-box',
|
||||
'scrollbar-width:thin',
|
||||
`scrollbar-color:${COLORS.accentHex} ${COLORS.panelHex}`,
|
||||
].join(';');
|
||||
|
||||
// Group opponents into pairs and build one row per pair
|
||||
for (let i = 0; i < opponents.length; i += 2) {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = `display:flex;gap:${GAP}px;flex-shrink:0;`;
|
||||
|
||||
const left = this.buildOpponentCardEl(opponents[i], CARD_H);
|
||||
row.appendChild(left);
|
||||
|
||||
if (opponents[i + 1]) {
|
||||
const right = this.buildOpponentCardEl(opponents[i + 1], CARD_H);
|
||||
row.appendChild(right);
|
||||
} else {
|
||||
// Odd opponent out — add an invisible spacer so the card stays half-width
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'flex:1;';
|
||||
row.appendChild(spacer);
|
||||
}
|
||||
|
||||
wrapper.appendChild(row);
|
||||
}
|
||||
|
||||
this.add.dom(cx, OPP_SCROLL_TOP + OPP_SCROLL_H / 2, wrapper);
|
||||
}
|
||||
|
||||
buildOpponentCardEl(opp, cardH) {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = [
|
||||
'flex:1', // fill exactly half of the row
|
||||
`height:${cardH}px`,
|
||||
`background:${COLORS.panelHex}`,
|
||||
`border:2px solid ${COLORS.mutedHex}`,
|
||||
'border-radius:8px',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'gap:16px',
|
||||
'padding:12px 16px',
|
||||
'cursor:pointer',
|
||||
'box-sizing:border-box',
|
||||
'user-select:none',
|
||||
'transition:border-color 0.12s',
|
||||
].join(';');
|
||||
|
||||
// Portrait wrapper — fixed size so canvas/video can be stacked inside
|
||||
const portraitSize = cardH - 24;
|
||||
const portraitWrap = document.createElement('div');
|
||||
portraitWrap.style.cssText = `position:relative;width:${portraitSize}px;height:${portraitSize}px;flex-shrink:0;`;
|
||||
|
||||
// Static canvas (sprite frame with circular clip)
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = portraitSize;
|
||||
canvas.height = portraitSize;
|
||||
canvas.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;background:${COLORS.panelHex};`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const r = portraitSize / 2;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(r, r, r, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
|
||||
if (this.textures.exists('opponents')) {
|
||||
const frame = this.textures.getFrame('opponents', opp.spriteIndex ?? 0);
|
||||
ctx.drawImage(
|
||||
frame.source.image,
|
||||
frame.cutX, frame.cutY, frame.cutWidth, frame.cutHeight,
|
||||
0, 0, portraitSize, portraitSize,
|
||||
);
|
||||
} else {
|
||||
ctx.fillStyle = COLORS.panelHex;
|
||||
ctx.fillRect(0, 0, portraitSize, portraitSize);
|
||||
ctx.fillStyle = COLORS.accentHex;
|
||||
ctx.font = `bold ${Math.round(r * 0.8)}px system-ui,sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText((opp.name ?? '?').charAt(0).toUpperCase(), r, r);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Idle video (shown when selected, hidden otherwise)
|
||||
const video = document.createElement('video');
|
||||
video.src = `/assets/videos/${opp.id}-idle.mp4`;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;object-fit:cover;display:none;`;
|
||||
// If the video file doesn't exist, restore the canvas
|
||||
video.addEventListener('error', () => {
|
||||
video.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
});
|
||||
|
||||
portraitWrap.appendChild(canvas);
|
||||
portraitWrap.appendChild(video);
|
||||
|
||||
// Text block
|
||||
const info = document.createElement('div');
|
||||
info.style.cssText = 'display:flex;flex-direction:column;gap:6px;min-width:0;flex:1;';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.textContent = opp.name ?? '';
|
||||
name.style.cssText = [
|
||||
'font-family:system-ui,sans-serif',
|
||||
'font-size:22px',
|
||||
'font-weight:600',
|
||||
`color:${COLORS.textHex}`,
|
||||
'white-space:nowrap',
|
||||
'overflow:hidden',
|
||||
'text-overflow:ellipsis',
|
||||
].join(';');
|
||||
|
||||
const bio = document.createElement('div');
|
||||
bio.textContent = opp.bio ?? '';
|
||||
bio.style.cssText = [
|
||||
'font-family:system-ui,sans-serif',
|
||||
'font-size:15px',
|
||||
`color:${COLORS.mutedHex}`,
|
||||
'line-height:1.4',
|
||||
'overflow:hidden',
|
||||
'display:-webkit-box',
|
||||
'-webkit-line-clamp:2',
|
||||
'-webkit-box-orient:vertical',
|
||||
].join(';');
|
||||
|
||||
info.appendChild(name);
|
||||
info.appendChild(bio);
|
||||
el.appendChild(portraitWrap);
|
||||
el.appendChild(info);
|
||||
|
||||
el.addEventListener('click', () => this.toggleOpponent(opp, el));
|
||||
|
||||
this.cards.push({ opp, el, canvas, video });
|
||||
return el;
|
||||
}
|
||||
|
||||
toggleOpponent(opp, el) {
|
||||
const min = this.gameDef.minOpponents ?? 1;
|
||||
const max = this.gameDef.maxOpponents ?? 1;
|
||||
|
||||
if (this.selected.has(opp.id)) {
|
||||
this.selected.delete(opp.id);
|
||||
el.style.borderColor = COLORS.mutedHex;
|
||||
const card = this.cards.find((c) => c.opp.id === opp.id);
|
||||
if (card) {
|
||||
card.video.pause();
|
||||
card.video.style.display = 'none';
|
||||
card.canvas.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
// If already at max, bump the oldest selection out first
|
||||
if (this.selected.size >= max) {
|
||||
const oldestId = this.selected.values().next().value;
|
||||
this.selected.delete(oldestId);
|
||||
const oldCard = this.cards.find((c) => c.opp.id === oldestId);
|
||||
if (oldCard) {
|
||||
oldCard.el.style.borderColor = COLORS.mutedHex;
|
||||
oldCard.video.pause();
|
||||
oldCard.video.style.display = 'none';
|
||||
oldCard.canvas.style.display = 'block';
|
||||
}
|
||||
}
|
||||
this.selected.add(opp.id);
|
||||
el.style.borderColor = COLORS.accentHex;
|
||||
const card = this.cards.find((c) => c.opp.id === opp.id);
|
||||
if (card) {
|
||||
card.canvas.style.display = 'none';
|
||||
card.video.style.display = 'block';
|
||||
card.video.play().catch(() => {
|
||||
card.video.style.display = 'none';
|
||||
card.canvas.style.display = 'block';
|
||||
});
|
||||
}
|
||||
}
|
||||
this.startBtn.setEnabled(this.selected.size >= min);
|
||||
}
|
||||
|
||||
// ── Generic option section builder ─────────────────────────────────────────
|
||||
|
||||
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.add.text(cx, labelY, label, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '24px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const tileY = labelY + Math.max(82, Math.round(tileH / 2) + 18);
|
||||
const totalW = items.length * tileW + (items.length - 1) * tileGap;
|
||||
const startX = cx - totalW / 2 + tileW / 2;
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const x = startX + i * (tileW + tileGap);
|
||||
this.buildOptionTile(item, x, tileY, selectedProp, tilesProp, onSelect, tileW, tileH);
|
||||
});
|
||||
}
|
||||
|
||||
buildOptionTile(item, x, y, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H) {
|
||||
const container = this.add.container(x, y);
|
||||
const isSelected = this[selectedProp]?.id === item.id;
|
||||
|
||||
const bg = this.add.rectangle(0, 0, tileW, tileH, COLORS.panel)
|
||||
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
|
||||
const thumbW = tileW - 16;
|
||||
const thumbH = tileH - 30;
|
||||
let thumb;
|
||||
const thumbKey = item.key;
|
||||
if (thumbKey && this.textures.exists(thumbKey)) {
|
||||
const frame = item.spriteIndex ?? undefined;
|
||||
thumb = this.add.image(0, -8, thumbKey, frame)
|
||||
.setDisplaySize(thumbW, thumbH)
|
||||
.setOrigin(0.5);
|
||||
} else {
|
||||
const fallbackHex = item.fallbackColor ?? '#1a3a6b';
|
||||
const fallback = parseInt(fallbackHex.replace('#', ''), 16);
|
||||
thumb = this.add.rectangle(0, -8, thumbW, thumbH, fallback);
|
||||
}
|
||||
|
||||
const nameText = this.add.text(0, tileH / 2 - 11, item.name, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '13px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
container.add([bg, thumb, nameText]);
|
||||
|
||||
bg.on('pointerup', () => {
|
||||
this[selectedProp] = item;
|
||||
for (const t of this[tilesProp]) {
|
||||
t.bg.setStrokeStyle(3, t.item.id === item.id ? COLORS.accent : COLORS.muted);
|
||||
}
|
||||
onSelect(item);
|
||||
});
|
||||
bg.on('pointerover', () => {
|
||||
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.text);
|
||||
});
|
||||
bg.on('pointerout', () => {
|
||||
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.muted);
|
||||
});
|
||||
|
||||
this[tilesProp].push({ item, bg });
|
||||
}
|
||||
|
||||
applyDefault(cacheKey, defaultId, selectedProp, tilesProp) {
|
||||
const data = this.cache.json.get(cacheKey);
|
||||
const items = data?.playfields ?? data?.cardBacks ?? [];
|
||||
const def = items.find((i) => i.id === defaultId) ?? items[0] ?? null;
|
||||
if (!def) return;
|
||||
this[selectedProp] = def;
|
||||
for (const t of this[tilesProp]) {
|
||||
t.bg.setStrokeStyle(3, t.item.id === def.id ? COLORS.accent : COLORS.muted);
|
||||
}
|
||||
}
|
||||
|
||||
selectPlayfield(pf) { this.selectedPlayfield = pf; }
|
||||
selectCardBack(cb) { this.selectedCardBack = cb; }
|
||||
|
||||
// ── Start game ─────────────────────────────────────────────────────────────
|
||||
|
||||
startGame() {
|
||||
if (this.selected.size === 0) return;
|
||||
const opponents = this.cards
|
||||
.filter(({ opp }) => this.selected.has(opp.id))
|
||||
.map(({ opp }) => opp);
|
||||
this.scene.start('GameRoom', {
|
||||
game: this.gameDef,
|
||||
opponents,
|
||||
playfield: this.selectedPlayfield,
|
||||
cardBack: this.selectedCardBack,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
|
||||
|
|
@ -22,11 +22,36 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.on('progress', (p) => bar.width = barWidth * p);
|
||||
this.load.on('complete', () => { bg.destroy(); bar.destroy(); });
|
||||
|
||||
// Placeholder asset slot — drop sprite files here later, no asset is
|
||||
// required for the framework to boot.
|
||||
this.load.spritesheet('opponents', '/assets/images/opponents.png', {
|
||||
frameWidth: 300,
|
||||
frameHeight: 300,
|
||||
});
|
||||
this.load.spritesheet('cardbacks', '/assets/images/cardbacks.png', {
|
||||
frameWidth: 320,
|
||||
frameHeight: 420,
|
||||
});
|
||||
this.load.json('playfields', '/data/playfields.json');
|
||||
this.load.json('card-backs', '/data/card-backs.json');
|
||||
}
|
||||
|
||||
async create() {
|
||||
// Collect all image assets that need loading from JSON configs
|
||||
const pfd = this.cache.json.get('playfields');
|
||||
const cbd = this.cache.json.get('card-backs');
|
||||
|
||||
const toLoad = [
|
||||
...(pfd?.playfields ?? []).filter((pf) => pf.path && !this.textures.exists(pf.key)),
|
||||
...(cbd?.cardBacks ?? []).filter((cb) => cb.path && !this.textures.exists(cb.key)),
|
||||
];
|
||||
|
||||
if (toLoad.length > 0) {
|
||||
for (const asset of toLoad) this.load.image(asset.key, asset.path);
|
||||
await new Promise((resolve) => {
|
||||
this.load.once('complete', resolve);
|
||||
this.load.start();
|
||||
});
|
||||
}
|
||||
|
||||
await auth.refresh();
|
||||
this.scene.start('Landing');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
|
|
@ -78,14 +78,46 @@ export default class ProfileScene extends Phaser.Scene {
|
|||
}).setOrigin(0, 0.5);
|
||||
}
|
||||
|
||||
// Chip balance row
|
||||
const chipsY = 460;
|
||||
this.add.text(cx - 320, chipsY, 'Chip balance', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const chipsValueText = this.add.text(cx - 40, chipsY, `$${profile.chips.toLocaleString()}`, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '22px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
|
||||
// Financial reset — only visible when chips < $100
|
||||
let resetBtn = null;
|
||||
if (profile.chips < 100) {
|
||||
this.add.text(cx - 320, chipsY + 44, 'You are running low on chips.', {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '18px',
|
||||
color: COLORS.dangerHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
|
||||
resetBtn = new Button(this, cx + 200, chipsY + 44, 'Request financial reset', async () => {
|
||||
resetBtn.setEnabled(false);
|
||||
try {
|
||||
const { chips } = await api.post('/profile/chips/reset', {});
|
||||
profile.chips = chips;
|
||||
chipsValueText.setText(`$${chips.toLocaleString()}`);
|
||||
new Modal(this, `Chip balance reset to $${chips.toLocaleString()}.`, { autoCloseMs: 2000 });
|
||||
} catch (err) {
|
||||
new Modal(this, err.message, { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
resetBtn.setEnabled(true);
|
||||
}
|
||||
}, { variant: 'ghost', width: 300, fontSize: 18 });
|
||||
}
|
||||
|
||||
// Editable fields
|
||||
this.add.text(cx - 320, 480, 'Display name', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const displayNameInput = new TextInput(this, cx + 80, 520, { width: 600, value: profile.displayName ?? '', maxLength: 60 });
|
||||
this.add.text(cx - 320, 560, 'Display name', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const displayNameInput = new TextInput(this, cx + 80, 600, { width: 600, value: profile.displayName ?? '', maxLength: 60 });
|
||||
|
||||
this.add.text(cx - 320, 600, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const bioInput = new TextInput(this, cx + 80, 660, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
|
||||
this.add.text(cx - 320, 680, 'Bio', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const bioInput = new TextInput(this, cx + 80, 740, { width: 600, height: 120, multiline: true, value: profile.bio ?? '', maxLength: 500 });
|
||||
|
||||
new Button(this, cx - 200, 820, 'Save profile', async () => {
|
||||
new Button(this, cx - 200, 900, 'Save profile', async () => {
|
||||
try {
|
||||
const { profile: updated } = await api.patch('/profile', {
|
||||
displayName: displayNameInput.value,
|
||||
|
|
@ -98,8 +130,8 @@ export default class ProfileScene extends Phaser.Scene {
|
|||
}
|
||||
});
|
||||
|
||||
new Button(this, cx + 100, 820, 'Upload avatar', () => this.pickAvatar());
|
||||
new Button(this, cx + 400, 820, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
new Button(this, cx + 100, 900, 'Upload avatar', () => this.pickAvatar());
|
||||
new Button(this, cx + 400, 900, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
pickAvatar() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { COLORS } from '../config.js';
|
||||
|
||||
export class Button extends Phaser.GameObjects.Container {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
|
||||
export class Modal extends Phaser.GameObjects.Container {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
import { COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { api } from '../services/api.js';
|
||||
|
||||
// ── Shared portrait utility ───────────────────────────────────────────────────
|
||||
// Both factories add elements directly to the scene at world coordinates so
|
||||
// that GeometryMasks and DOM video elements work without container offsets.
|
||||
|
||||
function drawBacking(scene, x, y, radius, depth) {
|
||||
const g = scene.add.graphics().setDepth(depth);
|
||||
g.fillStyle(0x1a1a2e, 1);
|
||||
g.fillCircle(x, y, radius + 3);
|
||||
g.fillStyle(COLORS.panel, 1);
|
||||
g.fillCircle(x, y, radius + 1);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ── Opponent portrait (video + sprite fallback) ───────────────────────────────
|
||||
// Returns { playEmotion(emotion), destroy() }
|
||||
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth) {
|
||||
const size = radius * 2;
|
||||
|
||||
const backingG = drawBacking(scene, worldX, worldY, radius, depth);
|
||||
|
||||
// Always draw the sprite first as a fallback layer
|
||||
let spriteImg = null;
|
||||
if (scene.textures.exists('opponents')) {
|
||||
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff);
|
||||
maskG.fillCircle(worldX, worldY, radius);
|
||||
spriteImg = scene.add.image(worldX, worldY, 'opponents', opponent?.spriteIndex ?? 0)
|
||||
.setDisplaySize(size, size)
|
||||
.setMask(maskG.createGeometryMask())
|
||||
.setDepth(depth + 1);
|
||||
}
|
||||
|
||||
// Attempt video on top — CSS border-radius handles circular clipping
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.muted = true;
|
||||
videoEl.loop = true;
|
||||
videoEl.playsInline = true;
|
||||
videoEl.autoplay = true;
|
||||
videoEl.style.cssText = `width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;`;
|
||||
videoEl.src = `/assets/videos/${opponent?.id}-idle.mp4`;
|
||||
videoEl.play().catch(() => {});
|
||||
|
||||
// Hide the video element if the file doesn't exist so sprite shows through
|
||||
let videoError = false;
|
||||
videoEl.addEventListener('error', () => {
|
||||
videoEl.style.display = 'none';
|
||||
videoError = true;
|
||||
}, { once: true });
|
||||
|
||||
const domEl = scene.add.dom(worldX, worldY, videoEl).setDepth(depth + 2);
|
||||
|
||||
let emotionPlaying = false;
|
||||
|
||||
function playEmotion(emotion) {
|
||||
if (!opponent || emotionPlaying || videoEl.style.display === 'none') return;
|
||||
emotionPlaying = true;
|
||||
const idleSrc = `/assets/videos/${opponent.id}-idle.mp4`;
|
||||
const returnToIdle = () => {
|
||||
videoEl.onended = null;
|
||||
videoEl.onerror = null;
|
||||
videoEl.loop = true;
|
||||
videoEl.src = idleSrc;
|
||||
videoEl.play().catch(() => {});
|
||||
emotionPlaying = false;
|
||||
};
|
||||
videoEl.loop = false;
|
||||
videoEl.src = `/assets/videos/${opponent.id}-${emotion}.mp4`;
|
||||
videoEl.play().catch(returnToIdle);
|
||||
videoEl.onended = returnToIdle;
|
||||
videoEl.onerror = returnToIdle;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
backingG.setVisible(false);
|
||||
if (spriteImg) spriteImg.setVisible(false);
|
||||
domEl.setVisible(false);
|
||||
}
|
||||
|
||||
function show() {
|
||||
backingG.setVisible(true);
|
||||
if (spriteImg) spriteImg.setVisible(true);
|
||||
if (!videoError) domEl.setVisible(true);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
videoEl.pause();
|
||||
videoEl.src = '';
|
||||
}
|
||||
|
||||
scene.events.once('shutdown', destroy);
|
||||
|
||||
return { playEmotion, hide, show, destroy };
|
||||
}
|
||||
|
||||
// ── Player portrait (profile avatar with letter fallback) ─────────────────────
|
||||
// Returns { destroy() }
|
||||
export function createPlayerPortrait(scene, worldX, worldY, radius, depth, sceneName) {
|
||||
const size = radius * 2;
|
||||
|
||||
const backingG = drawBacking(scene, worldX, worldY, radius, depth);
|
||||
|
||||
const initial = (auth.user?.username ?? 'You').charAt(0).toUpperCase();
|
||||
const placeholder = scene.add.text(worldX, worldY, initial, {
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: `${Math.round(radius * 0.9)}px`,
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(depth + 1);
|
||||
|
||||
const allObjs = [backingG, placeholder];
|
||||
|
||||
// Async avatar load
|
||||
(async () => {
|
||||
try {
|
||||
const { profile } = await api.get('/profile');
|
||||
if (!profile?.avatarPath) return;
|
||||
if (!scene.scene.isActive(sceneName)) return;
|
||||
|
||||
const key = `player-avatar-${profile.id}`;
|
||||
if (!scene.textures.exists(key)) {
|
||||
await new Promise((resolve) => {
|
||||
scene.load.image(key, profile.avatarPath);
|
||||
scene.load.once('complete', resolve);
|
||||
scene.load.start();
|
||||
});
|
||||
}
|
||||
if (!scene.scene.isActive(sceneName)) return;
|
||||
|
||||
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff);
|
||||
maskG.fillCircle(worldX, worldY, radius);
|
||||
|
||||
placeholder.destroy();
|
||||
const avatarImg = scene.add.image(worldX, worldY, key)
|
||||
.setDisplaySize(size, size)
|
||||
.setMask(maskG.createGeometryMask())
|
||||
.setDepth(depth + 1);
|
||||
allObjs.push(avatarImg);
|
||||
} catch { /* placeholder remains */ }
|
||||
})();
|
||||
|
||||
function hide() { for (const o of allObjs) o.setVisible?.(false); }
|
||||
function show() { for (const o of allObjs) o.setVisible?.(true); }
|
||||
|
||||
return { hide, show, destroy() {} };
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import Phaser from 'phaser';
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
// DOM-overlay text input. Positions a real <input> element above the canvas
|
||||
// using the scene's scale so it lines up with where you'd draw it in Phaser.
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE profiles ADD COLUMN chips_balance INTEGER NOT NULL DEFAULT 2000;
|
||||
|
|
@ -6,9 +6,13 @@ export function registerGame(definition) {
|
|||
slug: definition.slug,
|
||||
name: definition.name ?? definition.slug,
|
||||
category: definition.category ?? 'tabletop',
|
||||
cardGame: definition.cardGame ?? false,
|
||||
minPlayers: definition.minPlayers ?? 2,
|
||||
maxPlayers: definition.maxPlayers ?? 2,
|
||||
minOpponents: definition.minOpponents ?? 1,
|
||||
maxOpponents: definition.maxOpponents ?? 1,
|
||||
supportsMultiplayer: definition.supportsMultiplayer ?? true,
|
||||
multiplayerOnly: definition.multiplayerOnly ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -21,7 +25,7 @@ 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 });
|
||||
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4 });
|
||||
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', minPlayers: 1, maxPlayers: 6 });
|
||||
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', minPlayers: 2, maxPlayers: 8 });
|
||||
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: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Router } from 'express';
|
|||
import multer from 'multer';
|
||||
import config from '../config.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { getProfile, setAvatarPath, updateProfile } from './service.js';
|
||||
import { getProfile, setAvatarPath, updateProfile, getChipsBalance, adjustChipsBalance, resetChipsBalance } from './service.js';
|
||||
|
||||
fs.mkdirSync(config.uploads.dir, { recursive: true });
|
||||
|
||||
|
|
@ -53,4 +53,26 @@ router.post('/avatar', requireAuth, (req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
router.get('/chips', requireAuth, (req, res) => {
|
||||
res.json({ chips: getChipsBalance(req.user.id) });
|
||||
});
|
||||
|
||||
router.post('/chips/adjust', requireAuth, (req, res) => {
|
||||
const { delta } = req.body ?? {};
|
||||
if (typeof delta !== 'number' || !Number.isInteger(delta)) {
|
||||
return res.status(400).json({ error: 'delta must be an integer.' });
|
||||
}
|
||||
const chips = adjustChipsBalance(req.user.id, delta);
|
||||
res.json({ chips });
|
||||
});
|
||||
|
||||
router.post('/chips/reset', requireAuth, (req, res) => {
|
||||
try {
|
||||
const chips = resetChipsBalance(req.user.id);
|
||||
res.json({ chips });
|
||||
} catch (err) {
|
||||
res.status(err.status ?? 500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export function getProfile(userId) {
|
|||
const row = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.email, u.username, u.email_verified,
|
||||
p.display_name, p.avatar_path, p.bio, p.updated_at
|
||||
p.display_name, p.avatar_path, p.bio, p.chips_balance, p.updated_at
|
||||
FROM users u LEFT JOIN profiles p ON p.user_id = u.id
|
||||
WHERE u.id = ?`,
|
||||
)
|
||||
|
|
@ -18,6 +18,7 @@ export function getProfile(userId) {
|
|||
displayName: row.display_name,
|
||||
avatarPath: row.avatar_path,
|
||||
bio: row.bio,
|
||||
chips: row.chips_balance ?? 2000,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
|
@ -46,3 +47,31 @@ export function setAvatarPath(userId, avatarPath) {
|
|||
).run(avatarPath, userId);
|
||||
return getProfile(userId);
|
||||
}
|
||||
|
||||
export function getChipsBalance(userId) {
|
||||
const row = db.prepare('SELECT chips_balance FROM profiles WHERE user_id = ?').get(userId);
|
||||
return row?.chips_balance ?? 2000;
|
||||
}
|
||||
|
||||
export function adjustChipsBalance(userId, delta) {
|
||||
const current = getChipsBalance(userId);
|
||||
const next = Math.max(0, current + delta);
|
||||
db.prepare(
|
||||
`UPDATE profiles SET chips_balance = ?, updated_at = datetime('now') WHERE user_id = ?`,
|
||||
).run(next, userId);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resetChipsBalance(userId) {
|
||||
const current = getChipsBalance(userId);
|
||||
if (current >= 100) {
|
||||
const err = new Error('Financial reset is only available when your chip balance is below $100.');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
const RESET_AMOUNT = 2000;
|
||||
db.prepare(
|
||||
`UPDATE profiles SET chips_balance = ?, updated_at = datetime('now') WHERE user_id = ?`,
|
||||
).run(RESET_AMOUNT, userId);
|
||||
return RESET_AMOUNT;
|
||||
}
|
||||
|
|
|
|||