Commit 6d2431c7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: implement full backgammon game — rules engine, bot AI, canvas renderer, multiplayer

- Port backgammonjs-master rules to ES modules (standard, gul bara, tapa variants)
- 3-tier bot AI (easy/medium/hard) with positional evaluation
- Doubling cube with Crawford rule for match play
- Canvas board renderer with piece stacking, hit/bear-off animations
- Room scene with variant/length/difficulty selection
- Full multiplayer via backgammon-match.php (queue, move, heartbeat, leave)
- Realtime subscription for backgammon_matches table
- Emote panel + phrase system (reuses Ludo pattern)
- 7 new SFX slots in branding admin (dice, move, hit, bear, double, win)
- Match system (first-to-N, gammon/backgammon scoring multipliers)
- Wired into engine.js, play table, queue routing, match-session recovery
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e4824004
......@@ -510,6 +510,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
['slot' => 'sfx_piece_home', 'label' => '🏠 وصول البيت (Piece Home)', 'hint' => 'قطعة وصلت المركز — لودو'],
['slot' => 'sfx_turn_start', 'label' => '🔔 دورك (Turn Start)', 'hint' => 'تنبيه بدء دورك — لودو/دومينو'],
['slot' => 'sfx_emote', 'label' => '💬 إيموت (Emote)', 'hint' => 'إرسال إيموجي/عبارة اجتماعية'],
['slot' => 'sfx_bg_dice_roll', 'label' => '🎲 نرد طاولة (BG Dice Roll)', 'hint' => 'رمي النرد — طاولة/باكگمون'],
['slot' => 'sfx_bg_piece_move', 'label' => '♟️ حركة قطعة (BG Move)', 'hint' => 'تحريك قطعة على البورد — طاولة'],
['slot' => 'sfx_bg_piece_hit', 'label' => '💥 أكل قطعة (BG Hit)', 'hint' => 'ضرب قطعة الخصم للبار — طاولة'],
['slot' => 'sfx_bg_bear_off', 'label' => '✅ تطليع (BG Bear Off)', 'hint' => 'إخراج قطعة من اللوح — طاولة'],
['slot' => 'sfx_bg_double', 'label' => '⬆️ مضاعفة (BG Double)', 'hint' => 'عرض مكعب المضاعفة — طاولة'],
['slot' => 'sfx_bg_win_game', 'label' => '🎉 فوز جولة (BG Win Game)', 'hint' => 'فوز بجولة واحدة — طاولة'],
['slot' => 'sfx_bg_win_match', 'label' => '🏆 فوز ماتش (BG Win Match)', 'hint' => 'فوز بالماتش كامل — طاولة'],
];
foreach ($soundSlots as $s):
$current = $theme['assets'][$s['slot']] ?? null;
......
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
switch ($action) {
case 'queue': handleQueue($userId, $input); break;
case 'status': handleStatus($userId); break;
case 'move': handleMove($userId, $input); break;
case 'get': handleGet($userId, $input); break;
case 'complete': handleComplete($userId, $input); break;
case 'heartbeat': handleBgHeartbeat($userId, $input); break;
case 'leave': handleBgLeave($userId, $input); break;
case 'emote': handleEmote($userId, $input); break;
default: jsonError('Invalid action');
}
function handleQueue(string $userId, array $input): void {
$sdb = supabaseService();
$variant = $input['variant'] ?? 'standard';
$matchLength = intval($input['match_length'] ?? 3);
// Clean old queue entries
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
// Search for opponent
$searchUrl = SUPABASE_REST . '/backgammon_queue'
. '?user_id=neq.' . $userId
. '&match_id=is.null'
. '&variant=eq.' . $variant
. '&select=id,user_id'
. '&limit=1';
$ch = curl_init($searchUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'apikey: ' . SUPABASE_SERVICE_KEY,
'Authorization: Bearer ' . SUPABASE_SERVICE_KEY,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
curl_close($ch);
$opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) {
$opponent = $opponents[0];
$players = [$opponent['user_id'], $userId];
// Randomly assign colors
$whiteIdx = rand(0, 1);
$colors = [$whiteIdx === 0 ? 'white' : 'black', $whiteIdx === 1 ? 'white' : 'black'];
$matchData = [
'status' => 'in_progress',
'players' => json_encode($players),
'colors' => json_encode($colors),
'variant' => $variant,
'match_length' => $matchLength,
'scores' => json_encode([0, 0]),
'current_turn' => $whiteIdx,
'game_state' => json_encode([
'variant' => $variant,
'match_length' => $matchLength,
'game_number' => 1,
'cube_value' => 1,
'cube_owner' => null,
'crawford' => false
]),
'host_id' => $opponent['user_id'],
'created_by' => $userId
];
$match = $sdb->insert('backgammon_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) {
$sdb->update('backgammon_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opponent['id']]);
jsonResponse([
'match_id' => $matchId,
'player_index' => 1,
'players' => $players,
'color' => $colors[1]
]);
return;
}
}
// No opponent found — queue up
$sdb->insert('backgammon_queue', [
'user_id' => $userId,
'variant' => $variant,
'match_length' => $matchLength
]);
jsonResponse(['queued' => true]);
}
function handleStatus(string $userId): void {
$sdb = supabaseService();
$entry = $sdb->get('backgammon_queue', [
'user_id' => 'eq.' . $userId,
'select' => 'id,match_id',
'limit' => 1
]);
if (!empty($entry) && !isset($entry['error']) && !empty($entry[0]['match_id'])) {
$matchId = $entry[0]['match_id'];
$sdb->delete('backgammon_queue', ['user_id' => 'eq.' . $userId]);
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,colors', 'limit' => 1]);
$players = [];
$colors = [];
if (!empty($matches) && !isset($matches['error'])) {
$players = json_decode($matches[0]['players'] ?? '[]', true);
$colors = json_decode($matches[0]['colors'] ?? '[]', true);
}
$playerIndex = array_search($userId, $players);
jsonResponse([
'match_id' => $matchId,
'player_index' => $playerIndex !== false ? $playerIndex : 0,
'players' => $players,
'color' => $colors[$playerIndex] ?? 'white'
]);
return;
}
jsonResponse(['waiting' => true]);
}
function handleMove(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$update = [];
if (isset($input['game_state'])) {
$gs = is_string($input['game_state']) ? $input['game_state'] : json_encode($input['game_state']);
$update['game_state'] = $gs;
}
if (isset($input['match_state'])) {
$ms = is_string($input['match_state']) ? json_decode($input['match_state'], true) : $input['match_state'];
if (isset($ms['scores'])) $update['scores'] = json_encode($ms['scores']);
if (isset($ms['gameNumber'])) $update['game_number'] = $ms['gameNumber'];
}
if (isset($input['cube'])) {
$update['cube_state'] = is_string($input['cube']) ? $input['cube'] : json_encode($input['cube']);
}
if (isset($input['current_turn'])) {
$update['current_turn'] = intval($input['current_turn']);
}
$update['last_activity'] = date('c');
$update['updated_at'] = date('c');
if (!empty($update)) {
$sdb->update('backgammon_matches', $update, ['id' => 'eq.' . $matchId]);
}
jsonResponse(['success' => true]);
}
function handleGet(string $userId, array $input): void {
$matchId = $input['match_id'] ?? ($_GET['match_id'] ?? '');
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => '*', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found', 404);
$match = $matches[0];
// Server-side turn timeout (30s)
if ($match['status'] === 'in_progress') {
$timeout = supabaseRpc('enforce_turn_timeout', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_timeout_seconds' => 30
]);
if (!empty($timeout['timeout'])) {
$match['_turn_timed_out'] = true;
$match['_timeout_player'] = $timeout['player_index'] ?? null;
}
}
jsonResponse($match);
}
function handleComplete(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$winner = $input['winner'] ?? null;
$reason = $input['reason'] ?? 'normal';
$sdb = supabaseService();
$sdb->update('backgammon_matches', [
'status' => 'completed',
'completed_at' => date('c'),
'winner_id' => $winner,
'result' => $reason,
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
$result = supabaseRpc('complete_match', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_winners' => json_encode([$winner]),
'p_reason' => $reason
]);
jsonResponse($result ?: ['success' => true]);
}
function handleBgHeartbeat(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$result = supabaseRpc('match_heartbeat', [
'p_game_key' => 'backgammon',
'p_match_id' => $matchId,
'p_player_id' => $userId
]);
jsonResponse($result ?: ['status' => 'ok']);
}
function handleBgLeave(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$matches = $sdb->get('backgammon_matches', ['id' => 'eq.' . $matchId, 'select' => 'players,game_state', 'limit' => 1]);
if (empty($matches) || isset($matches['error'])) jsonError('Not found');
$gameState = json_decode($matches[0]['game_state'] ?? '{}', true);
$gameState['left_by'] = $userId;
$gameState['left_at'] = date('c');
$sdb->update('backgammon_matches', [
'game_state' => json_encode($gameState),
'status' => 'abandoned',
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
}
function handleEmote(string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$sdb->update('backgammon_matches', [
'last_emote' => json_encode([
'user_id' => $userId,
'emote' => $input['emote'] ?? '',
'at' => date('c')
]),
'updated_at' => date('c')
], ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
}
......@@ -26,7 +26,14 @@ const sounds = {
sfx_boost: { src: null, freq: 880, dur: 0.25 },
sfx_piece_home: { src: null, freq: 660, dur: 0.35 },
sfx_turn_start: { src: null, freq: 520, dur: 0.15 },
sfx_emote: { src: null, freq: 440, dur: 0.08 }
sfx_emote: { src: null, freq: 440, dur: 0.08 },
sfx_bg_dice_roll: { src: null, freq: 180, dur: 0.4 },
sfx_bg_piece_move: { src: null, freq: 400, dur: 0.06 },
sfx_bg_piece_hit: { src: null, freq: 150, dur: 0.2 },
sfx_bg_bear_off: { src: null, freq: 600, dur: 0.25 },
sfx_bg_double: { src: null, freq: 120, dur: 0.3 },
sfx_bg_win_game: { src: null, freq: 523, dur: 0.5 },
sfx_bg_win_match: { src: null, freq: 660, dur: 0.7 }
};
function getCtx() {
......
......@@ -92,7 +92,7 @@ function startPolling() {
if (currentSession.isBackground) return; // Don't poll when tab is hidden
try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : currentSession.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
const data = await net.post(endpoint, {
action: 'get',
match_id: currentSession.matchId
......@@ -128,7 +128,7 @@ function startPinging() {
currentSession.pingTimer = setInterval(async () => {
if (!currentSession || !currentSession.isActive) return;
try {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : currentSession.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
await net.post(endpoint, {
action: 'heartbeat',
match_id: currentSession.matchId
......@@ -171,7 +171,7 @@ function setupVisibilityHandler() {
// Immediately fetch latest state
if (currentSession.onOpponentMove) {
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : 'game.php';
const endpoint = currentSession.gameType === 'ludo' ? 'ludo-match.php' : currentSession.gameType === 'domino' ? 'domino-match.php' : currentSession.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => {
if (data && !data.error) {
......
......@@ -102,6 +102,11 @@ export function subscribeLudoMatch(matchId, callback) {
return subscribe('ludo_matches', `id=eq.${matchId}`, callback);
}
// Subscribe to a specific backgammon match row
export function subscribeBackgammonMatch(matchId, callback) {
return subscribe('backgammon_matches', `id=eq.${matchId}`, callback);
}
// Subscribe to matchmaking queue for match found
export function subscribeQueue(playerId, callback) {
return subscribe('matchmaking_queue', `player_id=eq.${playerId}`, callback);
......
......@@ -57,7 +57,7 @@ async function boot() {
if (recoverable) {
// Verify match is still running on server before reconnecting
try {
const endpoint = recoverable.gameType === 'ludo' ? 'ludo-match.php' : recoverable.gameType === 'domino' ? 'domino-match.php' : 'game.php';
const endpoint = recoverable.gameType === 'ludo' ? 'ludo-match.php' : recoverable.gameType === 'domino' ? 'domino-match.php' : recoverable.gameType === 'backgammon' ? 'backgammon-match.php' : 'game.php';
const res = await fetch(`/api/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
......@@ -134,6 +134,7 @@ async function loadModules() {
await import('./modules/chess/mod.js');
await import('./modules/domino/mod.js');
await import('./modules/ludo/mod.js');
await import('./modules/backgammon/mod.js');
await import('./modules/rank/mod.js');
await import('./modules/social/mod.js');
await import('./modules/tournaments/mod.js');
......
// Board renderer — draws the backgammon board on canvas
import { WHITE, BLACK } from '../logic/rules.js';
const CHECKER_RADIUS = 16;
const MAX_VISIBLE = 5;
export function drawBoard(ctx, w, h, state, options = {}) {
const { highlights = [], selectedPoint = null, theme = {} } = options;
const boardColor = theme.board || '#2d5016';
const borderColor = theme.border || '#5c3a1e';
const pointDark = theme.pointDark || '#8B4513';
const pointLight = theme.pointLight || '#D2691E';
const barColor = theme.bar || '#3d2b1a';
const margin = 8;
const barWidth = w * 0.06;
const bearOffWidth = w * 0.07;
const boardW = w - margin * 2;
const boardH = h - margin * 2;
const innerW = boardW - bearOffWidth;
const innerH = boardH;
const pointW = (innerW - barWidth) / 12;
const pointH = innerH * 0.42;
ctx.clearRect(0, 0, w, h);
// Board background
ctx.fillStyle = boardColor;
ctx.fillRect(margin, margin, boardW, boardH);
// Border
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(margin, margin, boardW, boardH);
// Bar
const barX = margin + 6 * pointW;
ctx.fillStyle = barColor;
ctx.fillRect(barX, margin, barWidth, boardH);
// Bear-off tray
const bearX = margin + innerW;
ctx.fillStyle = barColor;
ctx.fillRect(bearX, margin, bearOffWidth, boardH);
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.strokeRect(bearX, margin, bearOffWidth, boardH);
// Draw points (triangles)
for (let i = 0; i < 24; i++) {
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH);
const color = i % 2 === 0 ? pointDark : pointLight;
ctx.beginPath();
if (isTop) {
ctx.moveTo(x, y);
ctx.lineTo(x + pointW, y);
ctx.lineTo(x + pointW / 2, y + pointH);
} else {
ctx.moveTo(x, y);
ctx.lineTo(x + pointW, y);
ctx.lineTo(x + pointW / 2, y - pointH);
}
ctx.closePath();
// Highlight valid destinations
if (highlights.includes(i)) {
ctx.fillStyle = 'rgba(34, 197, 94, 0.4)';
ctx.fill();
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 2;
ctx.stroke();
} else if (selectedPoint === i) {
ctx.fillStyle = 'rgba(251, 191, 36, 0.3)';
ctx.fill();
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.stroke();
} else {
ctx.fillStyle = color;
ctx.fill();
}
}
// Draw checkers on points
for (let i = 0; i < 24; i++) {
const pieces = state.points[i];
if (pieces.length === 0) continue;
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH);
const cx = x + pointW / 2;
const count = pieces.length;
const visibleCount = Math.min(count, MAX_VISIBLE);
const spacing = Math.min(CHECKER_RADIUS * 2, pointH / visibleCount);
for (let j = 0; j < visibleCount; j++) {
const cy = isTop
? y + CHECKER_RADIUS + j * spacing
: y - CHECKER_RADIUS - j * spacing;
drawChecker(ctx, cx, cy, pieces[j].type, theme);
}
if (count > MAX_VISIBLE) {
const lastY = isTop
? y + CHECKER_RADIUS + (visibleCount - 1) * spacing
: y - CHECKER_RADIUS - (visibleCount - 1) * spacing;
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, lastY);
}
}
// Draw bar pieces
drawBarPieces(ctx, state, barX, barWidth, margin, boardH, theme);
// Draw bear-off pieces
drawBearOffPieces(ctx, state, bearX, bearOffWidth, margin, boardH, theme);
// Highlight bear-off if valid destination
if (highlights.includes('off')) {
ctx.fillStyle = 'rgba(34, 197, 94, 0.3)';
ctx.fillRect(bearX + 2, margin + 2, bearOffWidth - 4, boardH - 4);
ctx.strokeStyle = '#22c55e';
ctx.lineWidth = 2;
ctx.strokeRect(bearX + 2, margin + 2, bearOffWidth - 4, boardH - 4);
}
return { margin, pointW, pointH, barWidth, barX, boardH, bearX, bearOffWidth, innerW };
}
function drawChecker(ctx, x, y, type, theme) {
const whiteColor = theme.checkerWhite || '#f8fafc';
const blackColor = theme.checkerBlack || '#1e293b';
const color = type === WHITE ? whiteColor : blackColor;
const borderCol = type === WHITE ? '#94a3b8' : '#475569';
ctx.beginPath();
ctx.arc(x, y, CHECKER_RADIUS - 1, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = borderCol;
ctx.lineWidth = 2;
ctx.stroke();
// Inner ring
ctx.beginPath();
ctx.arc(x, y, CHECKER_RADIUS - 5, 0, Math.PI * 2);
ctx.strokeStyle = type === WHITE ? '#cbd5e1' : '#64748b';
ctx.lineWidth = 1;
ctx.stroke();
}
function drawBarPieces(ctx, state, barX, barWidth, margin, boardH, theme) {
const cx = barX + barWidth / 2;
// White bar pieces (bottom half)
const whiteBar = state.bar[WHITE];
for (let i = 0; i < whiteBar.length; i++) {
const cy = margin + boardH / 2 + 20 + i * (CHECKER_RADIUS * 2 + 2);
drawChecker(ctx, cx, cy, WHITE, theme);
}
// Black bar pieces (top half)
const blackBar = state.bar[BLACK];
for (let i = 0; i < blackBar.length; i++) {
const cy = margin + boardH / 2 - 20 - i * (CHECKER_RADIUS * 2 + 2);
drawChecker(ctx, cx, cy, BLACK, theme);
}
}
function drawBearOffPieces(ctx, state, bearX, bearOffWidth, margin, boardH, theme) {
const cx = bearX + bearOffWidth / 2;
// White bear-off (bottom)
const whiteOff = state.outside[WHITE].length;
if (whiteOff > 0) {
const startY = margin + boardH - 10;
for (let i = 0; i < Math.min(whiteOff, 8); i++) {
ctx.fillStyle = theme.checkerWhite || '#f8fafc';
ctx.fillRect(cx - 10, startY - i * 8 - 8, 20, 6);
ctx.strokeStyle = '#94a3b8';
ctx.strokeRect(cx - 10, startY - i * 8 - 8, 20, 6);
}
if (whiteOff > 0) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(whiteOff), cx, startY - Math.min(whiteOff, 8) * 8 - 12);
}
}
// Black bear-off (top)
const blackOff = state.outside[BLACK].length;
if (blackOff > 0) {
const startY = margin + 10;
for (let i = 0; i < Math.min(blackOff, 8); i++) {
ctx.fillStyle = theme.checkerBlack || '#1e293b';
ctx.fillRect(cx - 10, startY + i * 8, 20, 6);
ctx.strokeStyle = '#475569';
ctx.strokeRect(cx - 10, startY + i * 8, 20, 6);
}
if (blackOff > 0) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(blackOff), cx, startY + Math.min(blackOff, 8) * 8 + 14);
}
}
}
export function getPointCoords(pointIndex, margin, pointW, pointH, barWidth, boardH) {
let x, y, isTop;
if (pointIndex >= 12) {
// Top row: 12-23 left to right
const col = pointIndex - 12;
x = margin + col * pointW + (col >= 6 ? barWidth : 0);
y = margin;
isTop = true;
} else {
// Bottom row: 11-0 left to right
const col = 11 - pointIndex;
x = margin + col * pointW + (col >= 6 ? barWidth : 0);
y = margin + boardH;
isTop = false;
}
return { x, y, isTop };
}
export function hitTest(canvasX, canvasY, layout, state) {
if (!layout) return null;
const { margin, pointW, pointH, barWidth, barX, boardH, bearX, bearOffWidth } = layout;
// Check bar area
if (canvasX >= barX && canvasX <= barX + barWidth) {
if (canvasY > margin + boardH / 2) return { type: 'bar', player: WHITE };
return { type: 'bar', player: BLACK };
}
// Check bear-off area
if (canvasX >= bearX && canvasX <= bearX + bearOffWidth) {
return { type: 'bearoff' };
}
// Check points
for (let i = 0; i < 24; i++) {
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH);
if (canvasX >= x && canvasX <= x + pointW) {
if (isTop && canvasY >= margin && canvasY <= margin + pointH + CHECKER_RADIUS * 2) {
return { type: 'point', index: i };
}
if (!isTop && canvasY >= margin + boardH - pointH - CHECKER_RADIUS * 2 && canvasY <= margin + boardH) {
return { type: 'point', index: i };
}
}
}
return null;
}
// Dice renderer — draws animated dice for backgammon
const DIE_SIZE = 36;
const DOT_RADIUS = 3.5;
const ROLL_FRAMES = 18;
const ROLL_DURATION = 600;
export function drawDice(ctx, x, y, values, options = {}) {
const { used = [], rolling = false, frame = 0 } = options;
for (let i = 0; i < values.length; i++) {
const dx = x + i * (DIE_SIZE + 10);
const val = rolling ? Math.floor(Math.random() * 6) + 1 : values[i];
const isUsed = used.includes(i);
drawSingleDie(ctx, dx, y, val, isUsed, rolling ? frame : 0);
}
}
function drawSingleDie(ctx, x, y, value, isUsed, frame) {
const rotation = frame > 0 ? (frame / ROLL_FRAMES) * Math.PI * 4 : 0;
ctx.save();
ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2);
if (frame > 0) ctx.rotate(rotation);
ctx.translate(-DIE_SIZE / 2, -DIE_SIZE / 2);
// Die body
ctx.fillStyle = isUsed ? 'rgba(200, 200, 200, 0.4)' : '#fffef0';
ctx.strokeStyle = isUsed ? 'rgba(150, 150, 150, 0.5)' : '#8B4513';
ctx.lineWidth = 1.5;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 5);
ctx.fill();
ctx.stroke();
if (isUsed) {
ctx.globalAlpha = 0.4;
}
// Dots
ctx.fillStyle = '#1a1a1a';
const positions = getDotPositions(value, DIE_SIZE);
for (const [dx, dy] of positions) {
ctx.beginPath();
ctx.arc(dx, dy, DOT_RADIUS, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function getDotPositions(value, size) {
const s = size;
const c = s / 2;
const q1 = s * 0.25;
const q3 = s * 0.75;
switch (value) {
case 1: return [[c, c]];
case 2: return [[q1, q3], [q3, q1]];
case 3: return [[q1, q3], [c, c], [q3, q1]];
case 4: return [[q1, q1], [q1, q3], [q3, q1], [q3, q3]];
case 5: return [[q1, q1], [q1, q3], [c, c], [q3, q1], [q3, q3]];
case 6: return [[q1, q1], [q1, c], [q1, q3], [q3, q1], [q3, c], [q3, q3]];
default: return [[c, c]];
}
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export function createRollAnimation(onFrame, onComplete) {
let frame = 0;
const startTime = performance.now();
function tick(now) {
const elapsed = now - startTime;
frame = Math.min(ROLL_FRAMES, Math.floor((elapsed / ROLL_DURATION) * ROLL_FRAMES));
onFrame(frame);
if (elapsed < ROLL_DURATION) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}
requestAnimationFrame(tick);
}
// Move animator — smooth piece transitions
import { getPointCoords } from './board-renderer.js';
const MOVE_DURATION = 300;
const HIT_DURATION = 400;
export function createMoveAnimation(from, to, layout, onFrame, onComplete) {
const startPos = getPixelPos(from, layout);
const endPos = getPixelPos(to, layout);
if (!startPos || !endPos) {
onComplete();
return;
}
const startTime = performance.now();
const duration = from === 'bar' || to === 'bar' ? HIT_DURATION : MOVE_DURATION;
function tick(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
const ease = 1 - Math.pow(1 - t, 3); // ease-out cubic
const x = startPos.x + (endPos.x - startPos.x) * ease;
const y = startPos.y + (endPos.y - startPos.y) * ease;
// Arc for hits (pieces going to bar)
let arcY = 0;
if (to === 'bar') {
arcY = -40 * Math.sin(t * Math.PI);
}
onFrame({ x, y: y + arcY, progress: t });
if (t < 1) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}
requestAnimationFrame(tick);
}
function getPixelPos(location, layout) {
if (!layout) return null;
const { margin, pointW, pointH, barWidth, barX, boardH, bearX, bearOffWidth } = layout;
if (location === 'bar') {
return { x: barX + barWidth / 2, y: margin + boardH / 2 };
}
if (location === 'off') {
return { x: bearX + bearOffWidth / 2, y: margin + boardH / 2 };
}
if (typeof location === 'number' && location >= 0 && location <= 23) {
const { x, y, isTop } = getPointCoords(location, margin, pointW, pointH, barWidth, boardH);
return {
x: x + pointW / 2,
y: isTop ? y + 20 : y - 20
};
}
return null;
}
// Bot AI for Backgammon — 3 difficulty levels
import { getValidMoves, applyMove, cloneState, getPipCount, WHITE, BLACK } from './rules.js';
export function getBotMove(game, difficulty = 'medium') {
const moves = getValidMoves(game);
if (moves.length === 0) return null;
if (difficulty === 'easy') return getEasyMove(moves);
if (difficulty === 'hard') return getHardMove(game, moves);
return getMediumMove(game, moves);
}
function getEasyMove(moves) {
return moves[Math.floor(Math.random() * moves.length)];
}
function getMediumMove(game, moves) {
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const score = scoreMoveSimple(game, move);
if (score > bestScore) {
bestScore = score;
best = move;
}
}
return best;
}
function getHardMove(game, moves) {
let best = moves[0];
let bestScore = -Infinity;
for (const move of moves) {
const score = scoreMoveAdvanced(game, move);
if (score > bestScore) {
bestScore = score;
best = move;
}
}
return best;
}
function scoreMoveSimple(game, move) {
let score = 0;
for (const action of move.actions) {
if (action.type === 'hit') score += 30;
if (action.type === 'bear') score += 50;
}
// Prefer moving from bar
if (move.from === 'bar') score += 20;
// Prefer making points (landing where we already have pieces)
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const destCount = game.state.points[move.to].filter(p => p.type === game.turn).length;
if (destCount === 1) score += 15; // making a point
}
// Avoid leaving blots
if (move.from >= 0 && move.from <= 23) {
const remaining = game.state.points[move.from].filter(p => p.type === game.turn).length - 1;
if (remaining === 1) score -= 10;
}
return score;
}
function scoreMoveAdvanced(game, move) {
let score = 0;
const type = game.turn;
const opp = type === WHITE ? BLACK : WHITE;
// Hit value — sending opponent back is huge
for (const action of move.actions) {
if (action.type === 'hit') score += 40;
if (action.type === 'bear') score += 60;
}
// Blot safety — penalize leaving blots
if (move.from >= 0 && move.from <= 23) {
const remaining = game.state.points[move.from].filter(p => p.type === type).length - 1;
if (remaining === 1) {
// How exposed is this blot?
const exposure = calculateBlotExposure(game.state, move.from, opp);
score -= exposure * 8;
}
}
// Point-making — landing to create a 2+ stack
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const friendsAtDest = game.state.points[move.to].filter(p => p.type === type).length;
if (friendsAtDest === 1) score += 20; // making a new point
if (friendsAtDest >= 2) score += 5; // adding to existing point
}
// Prime building — consecutive blocked points
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
score += primeBonus(game.state, move.to, type) * 8;
}
// Anchor value — holding points deep in opponent's board
if (move.to !== 'off' && move.to >= 0 && move.to <= 23) {
const norm = type === WHITE ? move.to : 23 - move.to;
if (norm >= 18) score += 12; // anchor in opponent's home
}
// Race — prefer bearing off and advancing when ahead
const myPips = getPipCount(game.state, type);
const oppPips = getPipCount(game.state, opp);
if (myPips < oppPips) {
score += move.steps * 2; // racing advantage, advance further
}
// Advancing runners from deep position
if (move.from >= 0 && move.from <= 23) {
const normFrom = type === WHITE ? move.from : 23 - move.from;
if (normFrom >= 20) score += 8; // escape back runners
}
return score;
}
function calculateBlotExposure(state, pos, opponentType) {
let threats = 0;
for (let dist = 1; dist <= 6; dist++) {
const fromPos = opponentType === WHITE ? pos + dist : pos - dist;
if (fromPos < 0 || fromPos > 23) continue;
if (state.points[fromPos].some(p => p.type === opponentType)) {
threats++;
}
}
// Also count bar pieces as threats
if (state.bar[opponentType].length > 0) {
const barEntry = opponentType === WHITE ? (23 - pos) : pos;
if (barEntry >= 1 && barEntry <= 6) threats += state.bar[opponentType].length;
}
return threats;
}
function primeBonus(state, point, type) {
let consecutive = 0;
for (let i = point - 3; i <= point + 3; i++) {
if (i < 0 || i > 23) continue;
if (state.points[i].filter(p => p.type === type).length >= 2) {
consecutive++;
} else {
if (i > point) break;
consecutive = 0;
}
}
return Math.max(0, consecutive - 1);
}
// Doubling Cube logic for Backgammon match play
import { WHITE, BLACK } from './rules.js';
export function createCube() {
return {
value: 1,
owner: null, // null = center (either can double), WHITE or BLACK = only that player can
offered: false,
offeredBy: null
};
}
export function canDouble(cube, playerType) {
if (cube.offered) return false;
if (cube.owner === null) return true;
return cube.owner === playerType;
}
export function offerDouble(cube, playerType) {
if (!canDouble(cube, playerType)) return false;
cube.offered = true;
cube.offeredBy = playerType;
return true;
}
export function acceptDouble(cube) {
if (!cube.offered) return false;
cube.value *= 2;
cube.owner = cube.offeredBy === WHITE ? BLACK : WHITE;
cube.offered = false;
cube.offeredBy = null;
return true;
}
export function declineDouble(cube) {
if (!cube.offered) return false;
cube.offered = false;
const decliner = cube.offeredBy === WHITE ? BLACK : WHITE;
cube.offeredBy = null;
return decliner; // returns who declined (and thus loses)
}
export function getStake(cube, gameScore) {
return cube.value * gameScore;
}
// Bot doubling decision based on pip count advantage
export function shouldBotDouble(cube, myPips, oppPips, difficulty) {
if (!canDouble(cube, BLACK)) return false;
const advantage = (oppPips - myPips) / oppPips;
if (difficulty === 'easy') return false;
if (difficulty === 'medium') return advantage > 0.25;
return advantage > 0.15; // hard bot doubles more aggressively
}
export function shouldBotAccept(cube, myPips, oppPips, difficulty) {
const disadvantage = (myPips - oppPips) / myPips;
if (difficulty === 'easy') return true; // easy bot always accepts
if (difficulty === 'medium') return disadvantage < 0.35;
return disadvantage < 0.25; // hard bot drops when too far behind
}
// Match system — first-to-N points, Crawford rule
import { WHITE, BLACK, createGame, getGameScore } from './rules.js';
import { createCube } from './doubling.js';
export function createMatch(matchLength = 5, variant = 'standard') {
return {
length: matchLength,
variant,
scores: [0, 0], // [WHITE, BLACK]
gameNumber: 0,
currentGame: null,
cube: null,
crawford: false,
crawfordUsed: false,
isOver: false,
winner: null,
postCrawford: false
};
}
export function startNewGame(match) {
match.gameNumber++;
match.currentGame = createGame(match.variant);
// Crawford rule: no doubling in the game immediately after someone reaches match-point - 1
const atMatchPoint = match.scores[WHITE] === match.length - 1 || match.scores[BLACK] === match.length - 1;
if (atMatchPoint && !match.crawfordUsed) {
match.crawford = true;
match.crawfordUsed = true;
match.cube = null;
} else {
match.crawford = false;
if (match.crawfordUsed) match.postCrawford = true;
match.cube = createCube();
}
// Alternate who goes first each game (simplified — real backgammon uses highest die)
if (match.gameNumber > 1) {
match.currentGame.turn = match.gameNumber % 2 === 0 ? BLACK : WHITE;
}
return match.currentGame;
}
export function endGame(match, winner, gameScore) {
const stake = match.cube ? match.cube.value * gameScore : gameScore;
match.scores[winner] += stake;
if (match.scores[winner] >= match.length) {
match.isOver = true;
match.winner = winner;
}
return {
winner,
gameScore,
stake,
scores: [...match.scores],
matchOver: match.isOver
};
}
export function canUseCube(match) {
return !match.crawford && match.cube !== null;
}
export function getMatchState(match) {
return {
length: match.length,
scores: [...match.scores],
gameNumber: match.gameNumber,
crawford: match.crawford,
isOver: match.isOver,
winner: match.winner
};
}
This diff is collapsed.
import * as scene from '../../core/scene.js';
import { mountGame, unmountGame } from './scenes/game.js';
import { mountResult } from './scenes/result.js';
import { mountRoom, unmountRoom } from './scenes/room.js';
scene.register('backgammon-game', mountGame, unmountGame);
scene.register('backgammon-result', mountResult);
scene.register('backgammon-room', mountRoom, unmountRoom);
This diff is collapsed.
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import { emoji } from '../../../core/theme.js';
export function mountResult(el, params) {
const { winner = 'you', scores = [0, 0], matchLength = 3, mode = 'bot', reason = '' } = params || {};
const didWin = winner === 'you';
audio.play(didWin ? 'sfx_bg_win_match' : 'lose', 'game');
el.innerHTML = `
<div class="bgr-wrap">
<div class="bgr-hero">
<div class="bgr-trophy">${didWin ? emoji('trophy', '🏆', 64) : emoji('pensive', '😔', 64)}</div>
<h1 class="bgr-title">${didWin ? 'مبروك! فزت!' : 'خسرت هالمرة'}</h1>
${reason === 'abandon' ? '<p class="bgr-reason">الخصم انسحب</p>' : ''}
</div>
<div class="bgr-scoreboard">
<div class="bgr-score-row">
<span class="bgr-score-label">أنت</span>
<span class="bgr-score-val ${didWin ? 'bgr-score-win' : ''}">${scores[0]}</span>
</div>
<div class="bgr-score-divider">من ${matchLength}</div>
<div class="bgr-score-row">
<span class="bgr-score-label">الخصم</span>
<span class="bgr-score-val ${!didWin ? 'bgr-score-win' : ''}">${scores[1]}</span>
</div>
</div>
<div class="bgr-buttons">
<button class="bgr-btn bgr-btn-primary" id="btn-rematch">
${emoji('fire', '🔥', 18)} العب مرة ثانية
</button>
<button class="bgr-btn bgr-btn-secondary" id="btn-exit">
رجوع للقائمة
</button>
</div>
</div>
${getResultStyles()}
`;
el.querySelector('#btn-rematch').onclick = () => {
audio.play('click');
scene.replace('backgammon-room', { mode: 'menu' });
};
el.querySelector('#btn-exit').onclick = () => {
audio.play('click');
scene.exitGameMode();
bus.emit('navigate', { world: 'play', scene: 'play-table' });
};
}
function getResultStyles() {
return `<style>
.bgr-wrap {
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:24px;
background:linear-gradient(180deg,#0a0f14 0%,#101820 50%,#141f2c 100%);
}
.bgr-hero { text-align:center;margin-bottom:32px; }
.bgr-trophy { font-size:64px;margin-bottom:16px;animation:bgFloat 3s ease-in-out infinite; }
.bgr-title { font-size:24px;font-weight:800;color:#f8fafc;margin:0; }
.bgr-reason { font-size:13px;color:#94a3b8;margin:8px 0 0; }
.bgr-scoreboard {
display:flex;align-items:center;gap:16px;
padding:20px 32px;border-radius:16px;
background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);
margin-bottom:32px;
}
.bgr-score-row { display:flex;flex-direction:column;align-items:center;gap:4px; }
.bgr-score-label { font-size:12px;color:#64748b; }
.bgr-score-val { font-size:32px;font-weight:800;color:#94a3b8; }
.bgr-score-win { color:#d4940a; }
.bgr-score-divider { font-size:11px;color:#475569; }
.bgr-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:280px; }
.bgr-btn {
padding:14px 24px;border-radius:14px;border:none;
font-size:15px;font-weight:700;cursor:pointer;text-align:center;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1);
}
.bgr-btn:active { transform:scale(0.95); }
.bgr-btn-primary { background:linear-gradient(135deg,#d4940a,#8B4513);color:#fff;box-shadow:0 4px 16px rgba(212,148,10,0.3); }
.bgr-btn-secondary { background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);color:#94a3b8; }
@keyframes bgFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
</style>`;
}
This diff is collapsed.
// Doubling cube UI overlay
export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) {
let existing = container.querySelector('.bgg-cube-display');
if (!existing) {
existing = document.createElement('div');
existing.className = 'bgg-cube-display';
container.appendChild(existing);
}
if (!cubeValue || cubeValue === 1) {
existing.style.display = 'none';
return;
}
existing.style.display = '';
const position = cubeOwner === null ? 'center' : cubeOwner === myColor ? 'bottom' : 'top';
existing.innerHTML = `
<div class="bgg-cube bgg-cube-${position}">
<span class="bgg-cube-val">${cubeValue}</span>
</div>
`;
}
// Emote panel for backgammon — reuses Ludo pattern
import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
const EMOTES = ['🎲', '😤', '🤔', '👏', '😂', '🔥', '💀', '🫡'];
const PHRASES = ['!لعبة حلوة', '!حركة ممتازة', '!حظ', '...فكّر أسرع', '?ريماتش', 'gg wp'];
const COOLDOWN = 3000;
let lastEmoteTime = 0;
export function createEmotePanel(container, options = {}) {
const { matchId, mode, onEmote } = options;
const panel = document.createElement('div');
panel.className = 'bgg-emote-panel';
panel.style.display = 'none';
panel.innerHTML = `
<div class="bgg-emotes">${EMOTES.map(e => `<button class="bgg-emote-btn">${e}</button>`).join('')}</div>
<div class="bgg-phrases">${PHRASES.map(p => `<button class="bgg-phrase-btn">${p}</button>`).join('')}</div>
`;
panel.querySelectorAll('.bgg-emote-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastEmoteTime < COOLDOWN) return;
lastEmoteTime = Date.now();
audio.play('sfx_emote');
onEmote?.(btn.textContent, 'emote');
panel.style.display = 'none';
if (mode === 'live' && matchId) {
net.post('backgammon-match.php', { action: 'emote', match_id: matchId, emote: btn.textContent }).catch(() => {});
}
};
});
panel.querySelectorAll('.bgg-phrase-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastEmoteTime < COOLDOWN) return;
lastEmoteTime = Date.now();
audio.play('sfx_emote');
onEmote?.(btn.textContent, 'phrase');
panel.style.display = 'none';
};
});
container.appendChild(panel);
return panel;
}
export function toggle(panel) {
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? '' : 'none';
}
// Move hints — shows valid destination indicators on the board
export function showHints(validMoves, selectedPieceId) {
if (!selectedPieceId) return [];
return validMoves
.filter(m => m.piece.id === selectedPieceId)
.map(m => m.to);
}
export function clearHints() {
return [];
}
......@@ -45,12 +45,14 @@ export function mountQueue(el, params) {
}
async function joinQueue(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : 'matchmaking.php';
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : params.game === 'backgammon' ? 'backgammon-match.php' : 'matchmaking.php';
try {
const data = await net.post(endpoint, {
action: 'queue',
game_key: params.game,
time_control: params.timeControl || 'standard'
time_control: params.timeControl || 'standard',
variant: params.variant,
match_length: params.matchLength
});
if (data.match_id) {
......@@ -64,7 +66,7 @@ async function joinQueue(params) {
}
function pollForMatch(params) {
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : 'matchmaking.php';
const endpoint = params.game === 'ludo' ? 'ludo-match.php' : params.game === 'domino' ? 'domino-match.php' : params.game === 'backgammon' ? 'backgammon-match.php' : 'matchmaking.php';
unsub = setInterval(async () => {
try {
const data = await net.post(endpoint, { action: 'status', game_key: params.game });
......
......@@ -56,7 +56,7 @@ export function mountTable(el) {
<!-- Games -->
<div class="games-grid">
${games.map(g => {
const disabled = g.key === 'backgammon';
const disabled = false;
return `
<div class="game-tile ${disabled ? 'game-tile-disabled' : ''}" data-game="${g.key}" style="--game-color:${g.color};--game-gradient:${g.gradient};">
<div class="game-tile-bg" style="background:${g.gradient};"></div>
......@@ -360,6 +360,8 @@ function showGameMenu(menu, game) {
scene.push('domino-room', { mode: 'bot-pick' });
} else if (game.key === 'ludo') {
scene.push('ludo-room', { mode: 'setup', type: 'local' });
} else if (game.key === 'backgammon') {
scene.push('backgammon-room', { mode: 'menu' });
} else {
const gameScene = game.key + '-game';
scene.push(gameScene, { mode: 'bot', game: game.key });
......@@ -384,6 +386,8 @@ function showGameMenu(menu, game) {
scene.push('play-time-select', { game: game.key, mode: 'human' });
} else if (game.key === 'ludo') {
scene.push('ludo-room', { mode: 'setup', type: 'online' });
} else if (game.key === 'backgammon') {
scene.push('backgammon-room', { mode: 'setup', type: 'online' });
} else {
scene.push('play-queue', { game: game.key, mode: 'human', timeControl: 'standard' });
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment