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'])) { ...@@ -510,6 +510,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
['slot' => 'sfx_piece_home', 'label' => '🏠 وصول البيت (Piece Home)', 'hint' => 'قطعة وصلت المركز — لودو'], ['slot' => 'sfx_piece_home', 'label' => '🏠 وصول البيت (Piece Home)', 'hint' => 'قطعة وصلت المركز — لودو'],
['slot' => 'sfx_turn_start', 'label' => '🔔 دورك (Turn Start)', 'hint' => 'تنبيه بدء دورك — لودو/دومينو'], ['slot' => 'sfx_turn_start', 'label' => '🔔 دورك (Turn Start)', 'hint' => 'تنبيه بدء دورك — لودو/دومينو'],
['slot' => 'sfx_emote', 'label' => '💬 إيموت (Emote)', '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): foreach ($soundSlots as $s):
$current = $theme['assets'][$s['slot']] ?? null; $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 = { ...@@ -26,7 +26,14 @@ const sounds = {
sfx_boost: { src: null, freq: 880, dur: 0.25 }, sfx_boost: { src: null, freq: 880, dur: 0.25 },
sfx_piece_home: { src: null, freq: 660, dur: 0.35 }, sfx_piece_home: { src: null, freq: 660, dur: 0.35 },
sfx_turn_start: { src: null, freq: 520, dur: 0.15 }, 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() { function getCtx() {
......
...@@ -92,7 +92,7 @@ function startPolling() { ...@@ -92,7 +92,7 @@ function startPolling() {
if (currentSession.isBackground) return; // Don't poll when tab is hidden if (currentSession.isBackground) return; // Don't poll when tab is hidden
try { 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, { const data = await net.post(endpoint, {
action: 'get', action: 'get',
match_id: currentSession.matchId match_id: currentSession.matchId
...@@ -128,7 +128,7 @@ function startPinging() { ...@@ -128,7 +128,7 @@ function startPinging() {
currentSession.pingTimer = setInterval(async () => { currentSession.pingTimer = setInterval(async () => {
if (!currentSession || !currentSession.isActive) return; if (!currentSession || !currentSession.isActive) return;
try { 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, { await net.post(endpoint, {
action: 'heartbeat', action: 'heartbeat',
match_id: currentSession.matchId match_id: currentSession.matchId
...@@ -171,7 +171,7 @@ function setupVisibilityHandler() { ...@@ -171,7 +171,7 @@ function setupVisibilityHandler() {
// Immediately fetch latest state // Immediately fetch latest state
if (currentSession.onOpponentMove) { 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 }) net.post(endpoint, { action: 'get', match_id: currentSession.matchId })
.then(data => { .then(data => {
if (data && !data.error) { if (data && !data.error) {
......
...@@ -102,6 +102,11 @@ export function subscribeLudoMatch(matchId, callback) { ...@@ -102,6 +102,11 @@ export function subscribeLudoMatch(matchId, callback) {
return subscribe('ludo_matches', `id=eq.${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 // Subscribe to matchmaking queue for match found
export function subscribeQueue(playerId, callback) { export function subscribeQueue(playerId, callback) {
return subscribe('matchmaking_queue', `player_id=eq.${playerId}`, callback); return subscribe('matchmaking_queue', `player_id=eq.${playerId}`, callback);
......
...@@ -57,7 +57,7 @@ async function boot() { ...@@ -57,7 +57,7 @@ async function boot() {
if (recoverable) { if (recoverable) {
// Verify match is still running on server before reconnecting // Verify match is still running on server before reconnecting
try { 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}`, { const res = await fetch(`/api/${endpoint}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
...@@ -134,6 +134,7 @@ async function loadModules() { ...@@ -134,6 +134,7 @@ async function loadModules() {
await import('./modules/chess/mod.js'); await import('./modules/chess/mod.js');
await import('./modules/domino/mod.js'); await import('./modules/domino/mod.js');
await import('./modules/ludo/mod.js'); await import('./modules/ludo/mod.js');
await import('./modules/backgammon/mod.js');
await import('./modules/rank/mod.js'); await import('./modules/rank/mod.js');
await import('./modules/social/mod.js'); await import('./modules/social/mod.js');
await import('./modules/tournaments/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
};
}
// Backgammon rules engine — ported from backgammonjs-master/lib/
// Supports Standard, Gul Bara, and Tapa variants
export const WHITE = 0;
export const BLACK = 1;
export const MOVE = 'move';
export const RECOVER = 'recover';
export const HIT = 'hit';
export const BEAR = 'bear';
// ===== STATE =====
export function createState() {
const points = [];
for (let i = 0; i < 24; i++) points.push([]);
return {
points,
bar: [[], []],
outside: [[], []],
nextId: 1
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
function place(state, count, type, position) {
for (let i = 0; i < count; i++) {
const piece = { type, id: state.nextId++ };
state.points[position].push(piece);
}
}
function countAtPos(state, position, type) {
return state.points[position].filter(p => p.type === type).length;
}
function countAllAtPos(state, position) {
return state.points[position].length;
}
function getTopPiece(state, position) {
const pt = state.points[position];
return pt.length > 0 ? pt[pt.length - 1] : null;
}
function getPiecePos(state, piece) {
for (let i = 0; i < state.points.length; i++) {
for (let k = 0; k < state.points[i].length; k++) {
if (state.points[i][k].id === piece.id) return i;
}
}
return null;
}
function isPieceOnBar(state, piece) {
return state.bar[piece.type].some(p => p.id === piece.id);
}
function isPieceOutside(state, piece) {
return state.outside[piece.type].some(p => p.id === piece.id);
}
function havePiecesOnBar(state, type) {
return state.bar[type].length > 0;
}
function getBarTopPiece(state, type) {
const bar = state.bar[type];
return bar.length > 0 ? bar[bar.length - 1] : null;
}
// ===== VARIANTS =====
const VARIANTS = {
standard: {
name: 'Standard',
allowHit: true,
resetState(state) {
place(state, 5, WHITE, 5);
place(state, 3, WHITE, 7);
place(state, 5, WHITE, 12);
place(state, 2, WHITE, 23);
place(state, 5, BLACK, 18);
place(state, 3, BLACK, 16);
place(state, 5, BLACK, 11);
place(state, 2, BLACK, 0);
},
incPos(pos, type, steps) {
return type === WHITE ? pos - steps : pos + steps;
},
normPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
},
denormPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
}
},
gulbara: {
name: 'Gul Bara',
allowHit: false,
resetState(state) {
place(state, 15, WHITE, 23);
place(state, 15, BLACK, 11);
},
incPos(pos, type, steps) {
if (type === WHITE) return pos - steps;
let np = pos - steps;
if (pos < 12 && np < 0) np = 24 + np;
else if (pos >= 12 && np <= 11) np = np - 12;
return np;
},
normPos(pos, type) {
if (type === WHITE) return pos;
if (pos < 0) return pos;
if (pos >= 12) return pos - 12;
return pos + 12;
},
denormPos(pos, type) {
if (type === WHITE) return pos;
if (pos < 0) return pos;
if (pos >= 12) return pos - 12;
return pos + 12;
}
},
tapa: {
name: 'Tapa',
allowHit: false,
resetState(state) {
place(state, 15, WHITE, 23);
place(state, 15, BLACK, 0);
},
incPos(pos, type, steps) {
return type === WHITE ? pos - steps : pos + steps;
},
normPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
},
denormPos(pos, type) {
return type === BLACK ? 23 - pos : pos;
}
}
};
export function getVariants() {
return Object.keys(VARIANTS).map(k => ({ key: k, name: VARIANTS[k].name }));
}
// ===== GAME =====
export function createGame(variant = 'standard') {
const rule = VARIANTS[variant];
const state = createState();
rule.resetState(state);
return {
variant,
state,
turn: WHITE,
dice: null,
movesLeft: [],
movesPlayed: [],
turnNumber: 0,
isOver: false,
winner: null,
score: 0
};
}
export function rollDice(game) {
const d1 = Math.floor(Math.random() * 6) + 1;
const d2 = Math.floor(Math.random() * 6) + 1;
const values = [d1, d2].sort((a, b) => b - a);
let moves = [...values];
if (d1 === d2) moves = [d1, d1, d1, d1];
const playable = calculatePlayableMoves(game.state, moves, game.turn, game.variant);
game.dice = values;
game.movesLeft = [...playable];
game.movesPlayed = [];
return { values, moves: playable };
}
export function getValidMoves(game) {
if (!game.dice || game.movesLeft.length === 0) return [];
const rule = VARIANTS[game.variant];
const state = game.state;
const type = game.turn;
const moves = [];
const uniqueSteps = [...new Set(game.movesLeft)];
for (const steps of uniqueSteps) {
const pieces = getMovablePieces(state, type, rule);
for (const piece of pieces) {
const actions = getMoveActions(state, piece, steps, rule);
if (actions.length > 0) {
if (!isMoveRestricted(state, game.movesLeft, piece, steps, type, rule)) {
const dest = getDestination(state, piece, steps, rule);
moves.push({ piece, steps, actions, from: getPiecePos(state, piece), to: dest });
}
}
}
}
return moves;
}
export function applyMove(game, pieceId, steps) {
const rule = VARIANTS[game.variant];
const state = game.state;
const piece = findPiece(state, pieceId, game.turn);
if (!piece) return null;
const actions = getMoveActions(state, piece, steps, rule);
if (actions.length === 0) return null;
applyActions(state, actions, rule);
const idx = game.movesLeft.indexOf(steps);
if (idx >= 0) {
game.movesLeft.splice(idx, 1);
game.movesPlayed.push(steps);
}
if (hasWon(state, game.turn)) {
game.isOver = true;
game.winner = game.turn;
game.score = getGameScore(state, game.turn, rule);
}
return actions;
}
export function nextTurn(game) {
game.turn = game.turn === WHITE ? BLACK : WHITE;
game.dice = null;
game.movesLeft = [];
game.movesPlayed = [];
game.turnNumber++;
}
export function hasWon(state, type) {
return state.outside[type].length === 15;
}
export function getGameScore(state, winner, rule) {
const opp = winner === WHITE ? BLACK : WHITE;
if (state.outside[opp].length === 0) {
const r = rule || VARIANTS.standard;
if (countAtHigherPos(state, 18, opp, r) > 0 || havePiecesOnBar(state, opp)) {
return 3; // backgammon
}
return 2; // gammon
}
return 1; // normal
}
export function getPipCount(state, type) {
const rule = VARIANTS.standard;
let pips = 0;
for (let i = 0; i < 24; i++) {
const count = countAtPos(state, i, type);
if (count > 0) {
const norm = rule.normPos(i, type);
pips += count * (norm + 1);
}
}
pips += state.bar[type].length * 25;
return pips;
}
// ===== INTERNAL HELPERS =====
function findPiece(state, pieceId, type) {
for (let i = 0; i < 24; i++) {
for (const p of state.points[i]) {
if (p.id === pieceId && p.type === type) return p;
}
}
for (const p of state.bar[type]) {
if (p.id === pieceId) return p;
}
return null;
}
function getMovablePieces(state, type, rule) {
if (havePiecesOnBar(state, type)) {
const top = getBarTopPiece(state, type);
return top ? [top] : [];
}
const pieces = [];
for (let i = 0; i < 24; i++) {
const pt = state.points[i];
if (pt.length > 0 && pt[pt.length - 1].type === type) {
pieces.push(pt[pt.length - 1]);
}
}
return pieces;
}
function getMoveActions(state, piece, steps, rule) {
const actions = [];
function addAction(type, piece, from, to) {
actions.push({ type, piece: { id: piece.id, type: piece.type }, from, to });
}
try {
if (havePiecesOnBar(state, piece.type)) {
if (!isPieceOnBar(state, piece)) return [];
const dest = piece.type === WHITE ? (24 - steps) : (steps - 1);
const top = getTopPiece(state, dest);
const topType = top ? top.type : null;
if (topType === null || topType === piece.type) {
addAction(RECOVER, piece, dest);
} else if (rule.allowHit && countAtPos(state, dest, topType) === 1) {
addAction(HIT, top, dest);
addAction(RECOVER, piece, dest);
}
return actions;
}
const position = getPiecePos(state, piece);
if (position === null) return [];
if (allPiecesAreHome(state, piece.type, rule)) {
const dest = rule.incPos(position, piece.type, steps);
const normDest = rule.normPos(dest, piece.type);
if (normDest >= 0) {
const top = getTopPiece(state, dest);
const topType = top ? top.type : null;
if (topType === null || topType === piece.type) {
addAction(MOVE, piece, position, dest);
} else if (rule.allowHit && countAtPos(state, dest, topType) === 1) {
addAction(HIT, top, dest);
addAction(MOVE, piece, position, dest);
} else if (!rule.allowHit && countAllAtPos(state, dest) === 1 && topType !== piece.type) {
// Tapa: can land on single opponent piece
addAction(MOVE, piece, position, dest);
}
} else if (normDest === -1) {
addAction(BEAR, piece, position);
} else {
const normSource = rule.normPos(position, piece.type);
if (countAtHigherPos(state, normSource + 1, piece.type, rule) <= 0) {
addAction(BEAR, piece, position);
}
}
} else {
const dest = rule.incPos(position, piece.type, steps);
if (dest < 0 || dest > 23) return [];
const top = getTopPiece(state, dest);
const topType = top ? top.type : null;
if (topType === null || topType === piece.type) {
addAction(MOVE, piece, position, dest);
} else if (rule.allowHit && countAtPos(state, dest, topType) === 1) {
addAction(HIT, top, dest);
addAction(MOVE, piece, position, dest);
} else if (!rule.allowHit && countAllAtPos(state, dest) === 1 && topType !== piece.type) {
addAction(MOVE, piece, position, dest);
}
}
} catch (e) {
return [];
}
return actions;
}
function getDestination(state, piece, steps, rule) {
if (isPieceOnBar(state, piece)) {
return piece.type === WHITE ? (24 - steps) : (steps - 1);
}
const pos = getPiecePos(state, piece);
if (pos === null) return null;
if (allPiecesAreHome(state, piece.type, rule)) {
const dest = rule.incPos(pos, piece.type, steps);
const norm = rule.normPos(dest, piece.type);
if (norm < 0) return 'off';
return dest;
}
return rule.incPos(pos, piece.type, steps);
}
function applyActions(state, actions, rule) {
for (const action of actions) {
const piece = findPieceById(state, action.piece.id, action.piece.type);
if (!piece) continue;
if (action.type === MOVE) {
const pos = getPiecePos(state, piece);
if (pos !== null) {
const idx = state.points[pos].findIndex(p => p.id === piece.id);
if (idx >= 0) {
state.points[pos].splice(idx, 1);
state.points[action.to].push(piece);
}
}
} else if (action.type === RECOVER) {
const idx = state.bar[piece.type].findIndex(p => p.id === piece.id);
if (idx >= 0) {
state.bar[piece.type].splice(idx, 1);
state.points[action.from].push(piece);
}
} else if (action.type === HIT) {
const pos = action.from;
const idx = state.points[pos].findIndex(p => p.id === piece.id);
if (idx >= 0) {
state.points[pos].splice(idx, 1);
state.bar[piece.type].push(piece);
}
} else if (action.type === BEAR) {
const pos = action.from;
const idx = state.points[pos].findIndex(p => p.id === piece.id);
if (idx >= 0) {
state.points[pos].splice(idx, 1);
state.outside[piece.type].push(piece);
}
}
}
}
function findPieceById(state, id, type) {
for (let i = 0; i < 24; i++) {
for (const p of state.points[i]) {
if (p.id === id) return p;
}
}
for (const p of state.bar[type]) {
if (p.id === id) return p;
}
return null;
}
function allPiecesAreHome(state, type, rule) {
return countAtHigherPos(state, 6, type, rule) <= 0;
}
function countAtHigherPos(state, normPosition, type, rule) {
let cnt = 0;
for (let i = 23; i >= normPosition; i--) {
const denorm = rule.denormPos(i, type);
if (denorm >= 0 && denorm <= 23) {
cnt += countAtPos(state, denorm, type);
}
}
return cnt;
}
function calculatePlayableMoves(state, moves, type, variant) {
const rule = VARIANTS[variant];
const weight = calcMoveWeights(state, moves, type, rule, null, true);
return weight.playableMoves.length > 0 ? weight.playableMoves : [];
}
function isMoveRestricted(state, movesLeft, piece, steps, type, rule) {
const weight = calcMoveWeights(state, movesLeft, type, rule, piece, false);
const maxW = weight.max;
const pieceW = weight[piece.id];
if (!pieceW || pieceW.max < maxW) return true;
return false;
}
function calcMoveWeights(state, movesLeft, type, rule, rootPiece, stopAtMax) {
const weight = { max: 0, playableMoves: [] };
const movesSum = movesLeft.reduce((a, b) => a + b, 0);
function branch(st, moves, id, sum, level, branchMoves) {
if (stopAtMax && weight.max >= movesSum) return;
const ml = [...moves];
const steps = ml.shift();
if (!steps) return;
const pieces = getMovablePiecesFromState(st, type, rule);
for (const piece of pieces) {
if (level === 0 && rootPiece && rootPiece.id !== piece.id) continue;
if (isPieceOutsideState(st, piece)) continue;
if (havePiecesOnBarState(st, type)) {
const barTop = getBarTopPieceState(st, type);
if (!barTop || barTop.id !== piece.id) continue;
} else {
const pos = getPiecePosState(st, piece);
if (pos === null) continue;
const top = st.points[pos].length > 0 ? st.points[pos][st.points[pos].length - 1] : null;
if (!top || top.id !== piece.id) continue;
}
const tempState = JSON.parse(JSON.stringify(st));
const tempPiece = findPieceInState(tempState, piece.id, type);
if (!tempPiece) continue;
const actions = getMoveActionsFromState(tempState, tempPiece, steps, rule);
if (actions.length === 0) continue;
applyActionsToState(tempState, actions, rule);
const tempMoves = [...branchMoves, steps];
const pieceID = id !== 0 ? id : piece.id;
if (!weight[pieceID]) weight[pieceID] = { max: 0, moves: [] };
const w = sum + steps;
if (w > weight[pieceID].max) weight[pieceID].max = w;
weight[pieceID].moves = tempMoves;
if (w > weight.max) {
weight.max = w;
weight.playableMoves = tempMoves;
}
if (stopAtMax && weight.max >= movesSum) return;
if (ml.length > 0) branch(tempState, ml, pieceID, w, level + 1, tempMoves);
}
}
branch(state, movesLeft, 0, 0, 0, []);
if (movesLeft.length > 1 && movesLeft[0] !== movesLeft[movesLeft.length - 1]) {
const reversed = [...movesLeft].reverse();
branch(state, reversed, 0, 0, 0, []);
}
return weight;
}
// State-only helpers for weight calculation (no mutation of original)
function getMovablePiecesFromState(state, type, rule) {
if (state.bar[type].length > 0) {
const top = state.bar[type].length > 0 ? state.bar[type][state.bar[type].length - 1] : null;
return top ? [top] : [];
}
const pieces = [];
for (let i = 0; i < 24; i++) {
const pt = state.points[i];
if (pt.length > 0 && pt[pt.length - 1].type === type) {
pieces.push(pt[pt.length - 1]);
}
}
return pieces;
}
function isPieceOutsideState(state, piece) {
return state.outside[piece.type].some(p => p.id === piece.id);
}
function havePiecesOnBarState(state, type) {
return state.bar[type].length > 0;
}
function getBarTopPieceState(state, type) {
const bar = state.bar[type];
return bar.length > 0 ? bar[bar.length - 1] : null;
}
function getPiecePosState(state, piece) {
for (let i = 0; i < state.points.length; i++) {
if (state.points[i].some(p => p.id === piece.id)) return i;
}
return null;
}
function findPieceInState(state, id, type) {
for (let i = 0; i < 24; i++) {
for (const p of state.points[i]) {
if (p.id === id) return p;
}
}
for (const p of state.bar[type]) {
if (p.id === id) return p;
}
return null;
}
function getMoveActionsFromState(state, piece, steps, rule) {
return getMoveActions(state, piece, steps, rule);
}
function applyActionsToState(state, actions, rule) {
applyActions(state, actions, rule);
}
// ===== SERIALIZATION (for multiplayer sync) =====
export function serializeState(game) {
return {
variant: game.variant,
state: game.state,
turn: game.turn,
dice: game.dice,
movesLeft: game.movesLeft,
movesPlayed: game.movesPlayed,
turnNumber: game.turnNumber,
isOver: game.isOver,
winner: game.winner,
score: game.score
};
}
export function deserializeState(data) {
return { ...data };
}
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);
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import * as net from '../../../core/net.js';
import * as matchSession from '../../../core/match-session.js';
import { emoji } from '../../../core/theme.js';
import { createGame, rollDice, getValidMoves, applyMove, nextTurn, hasWon, getPipCount, WHITE, BLACK, serializeState } from '../logic/rules.js';
import { getBotMove } from '../logic/bot.js';
import { createMatch, startNewGame, endGame, canUseCube, getMatchState } from '../logic/match.js';
import { createCube, canDouble, offerDouble, acceptDouble, declineDouble, shouldBotDouble, shouldBotAccept, getStake } from '../logic/doubling.js';
import { drawBoard, hitTest } from '../canvas/board-renderer.js';
import { drawDice, createRollAnimation } from '../canvas/dice-renderer.js';
import { createMoveAnimation } from '../canvas/move-animator.js';
let canvas, ctx, layout;
let game, match;
let selectedPiece = null;
let validMoves = [];
let highlights = [];
let isRolling = false;
let isAnimating = false;
let myColor = WHITE;
let params = {};
let emotePanel = null;
let turnTimer = null;
let turnTimeLeft = 30;
export function mountGame(el, p) {
params = p || {};
const { mode = 'bot', variant = 'standard', matchLength = 3, difficulty = 'medium', useCube = true } = params;
myColor = mode === 'live' ? (params.color === 'black' ? BLACK : WHITE) : WHITE;
// Create match
match = createMatch(matchLength, variant);
if (!useCube) match.cube = null;
el.innerHTML = `
<div class="bgg-container">
<div class="bgg-top-bar">
<div class="bgg-player-card bgg-player-top" id="player-top">
<div class="bgg-avatar">🎯</div>
<div class="bgg-player-info">
<div class="bgg-name" id="name-top">${mode === 'bot' ? 'بوت' : 'خصم'}</div>
<div class="bgg-pips" id="pips-top">167 نقطة</div>
</div>
<div class="bgg-score" id="score-top">0</div>
</div>
</div>
<div class="bgg-board-area">
<canvas id="bg-canvas" width="360" height="400"></canvas>
<div class="bgg-dice-area" id="dice-area">
<button class="bgg-roll-btn" id="btn-roll">${emoji('game_die', '🎲', 24)} ارمي النرد</button>
</div>
<div class="bgg-cube-area" id="cube-area" style="display:none;">
<button class="bgg-cube-btn" id="btn-double">×2 ضاعف</button>
</div>
<div class="bgg-double-offer" id="double-offer" style="display:none;">
<p>الخصم يضاعف! القيمة: <span id="double-val">2</span></p>
<button class="bgg-accept-btn" id="btn-accept-double">قبول</button>
<button class="bgg-decline-btn" id="btn-decline-double">رفض</button>
</div>
</div>
<div class="bgg-bottom-bar">
<div class="bgg-player-card bgg-player-bottom" id="player-bottom">
<div class="bgg-avatar">👤</div>
<div class="bgg-player-info">
<div class="bgg-name" id="name-bottom">أنت</div>
<div class="bgg-pips" id="pips-bottom">167 نقطة</div>
</div>
<div class="bgg-score" id="score-bottom">0</div>
</div>
<div class="bgg-actions">
<button class="bgg-action-btn" id="btn-emote">${emoji('sparkles', '✨', 18)}</button>
<button class="bgg-action-btn" id="btn-undo">↩️</button>
<button class="bgg-action-btn bgg-action-quit" id="btn-quit">✕</button>
</div>
</div>
<div class="bgg-turn-indicator" id="turn-indicator"></div>
<div class="bgg-emote-panel" id="emote-panel" style="display:none;"></div>
</div>
${getGameStyles()}
`;
canvas = el.querySelector('#bg-canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
// Start first game
startNewGameRound();
// Event listeners
canvas.addEventListener('click', onCanvasClick);
canvas.addEventListener('touchstart', onCanvasTouch, { passive: false });
el.querySelector('#btn-roll').onclick = onRollClick;
el.querySelector('#btn-double').onclick = onDoubleClick;
el.querySelector('#btn-accept-double').onclick = onAcceptDouble;
el.querySelector('#btn-decline-double').onclick = onDeclineDouble;
el.querySelector('#btn-emote').onclick = toggleEmotePanel;
el.querySelector('#btn-quit').onclick = onQuit;
window.addEventListener('resize', resizeCanvas);
// Multiplayer session
if (mode === 'live') {
matchSession.create(params.matchId, 'backgammon', {
onOpponentMove: handleServerState,
onOpponentDisconnect: () => bus.emit('toast', { text: 'الخصم انقطع...' }),
onOpponentAbandon: () => endMatchWithWin()
});
}
setupEmotePanel(el);
}
export function unmountGame() {
window.removeEventListener('resize', resizeCanvas);
matchSession.destroy();
clearInterval(turnTimer);
}
function startNewGameRound() {
game = startNewGame(match);
selectedPiece = null;
validMoves = [];
highlights = [];
isRolling = false;
isAnimating = false;
render();
updateUI();
updateTurnIndicator();
if (game.turn !== myColor && params.mode === 'bot') {
setTimeout(doBotTurn, 800);
}
}
// ===== RENDERING =====
function resizeCanvas() {
if (!canvas) return;
const container = canvas.parentElement;
const w = Math.min(container.clientWidth, 400);
const h = Math.min(w * 1.1, 440);
canvas.width = w;
canvas.height = h;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
render();
}
function render() {
if (!ctx || !game) return;
layout = drawBoard(ctx, canvas.width, canvas.height, game.state, {
highlights,
selectedPoint: selectedPiece !== null ? selectedPiece.from : null
});
// Draw dice if rolled
if (game.dice && !isRolling) {
const diceX = canvas.width / 2 - 45;
const diceY = canvas.height / 2 - 18;
const usedIndices = [];
// Mark dice as used based on movesPlayed
for (let i = 0; i < game.movesPlayed.length; i++) {
for (let j = 0; j < game.dice.length; j++) {
if (!usedIndices.includes(j) && game.dice[j] === game.movesPlayed[i]) {
usedIndices.push(j);
break;
}
}
}
const allMoves = game.dice[0] === game.dice[1] ? [game.dice[0], game.dice[0], game.dice[0], game.dice[0]] : game.dice;
drawDice(ctx, diceX, diceY, allMoves, { used: usedIndices });
}
}
function updateUI() {
if (!game) return;
const topColor = myColor === WHITE ? BLACK : WHITE;
const doc = canvas?.closest('.bgg-container');
if (!doc) return;
doc.querySelector('#pips-top').textContent = getPipCount(game.state, topColor) + ' نقطة';
doc.querySelector('#pips-bottom').textContent = getPipCount(game.state, myColor) + ' نقطة';
doc.querySelector('#score-top').textContent = match.scores[topColor];
doc.querySelector('#score-bottom').textContent = match.scores[myColor];
// Show/hide roll button
const rollBtn = doc.querySelector('#btn-roll');
const isMyTurn = game.turn === myColor;
rollBtn.style.display = (isMyTurn && !game.dice && !isAnimating) ? '' : 'none';
// Show/hide double button
const cubeArea = doc.querySelector('#cube-area');
if (match.cube && canUseCube(match) && isMyTurn && !game.dice && canDouble(match.cube, myColor)) {
cubeArea.style.display = '';
} else {
cubeArea.style.display = 'none';
}
}
function updateTurnIndicator() {
const indicator = canvas?.closest('.bgg-container')?.querySelector('#turn-indicator');
if (!indicator) return;
const isMyTurn = game.turn === myColor;
indicator.textContent = isMyTurn ? 'دورك' : 'دور الخصم';
indicator.className = `bgg-turn-indicator ${isMyTurn ? 'bgg-my-turn' : 'bgg-opp-turn'}`;
}
// ===== INPUT HANDLING =====
function onCanvasClick(e) {
if (isAnimating || isRolling) return;
if (game.turn !== myColor && params.mode !== 'local-multi') return;
if (!game.dice || game.movesLeft.length === 0) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
handleBoardTap(x, y);
}
function onCanvasTouch(e) {
if (isAnimating || isRolling) return;
if (game.turn !== myColor && params.mode !== 'local-multi') return;
if (!game.dice || game.movesLeft.length === 0) return;
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
handleBoardTap(x, y);
}
function handleBoardTap(x, y) {
const hit = hitTest(x, y, layout, game.state);
if (!hit) {
clearSelection();
return;
}
// If tapping a highlighted destination
if (selectedPiece && hit.type === 'point' && highlights.includes(hit.index)) {
executeMove(hit.index);
return;
}
if (selectedPiece && hit.type === 'bearoff' && highlights.includes('off')) {
executeMove('off');
return;
}
// Select a piece
if (hit.type === 'point') {
selectPoint(hit.index);
} else if (hit.type === 'bar' && hit.player === game.turn) {
selectBar();
} else {
clearSelection();
}
}
function selectPoint(pointIndex) {
const pieces = game.state.points[pointIndex];
if (pieces.length === 0 || pieces[pieces.length - 1].type !== game.turn) {
clearSelection();
return;
}
const piece = pieces[pieces.length - 1];
const moves = getValidMoves(game).filter(m => m.piece.id === piece.id);
if (moves.length === 0) {
clearSelection();
return;
}
selectedPiece = { id: piece.id, from: pointIndex, moves };
highlights = moves.map(m => m.to === 'off' ? 'off' : m.to);
audio.play('click');
render();
}
function selectBar() {
const moves = getValidMoves(game).filter(m => m.from === 'bar' || m.from === null);
if (moves.length === 0) return;
selectedPiece = { id: moves[0].piece.id, from: 'bar', moves };
highlights = moves.map(m => m.to);
audio.play('click');
render();
}
function clearSelection() {
selectedPiece = null;
highlights = [];
render();
}
function executeMove(destination) {
if (!selectedPiece) return;
const move = selectedPiece.moves.find(m => m.to === destination);
if (!move) return;
isAnimating = true;
audio.play('sfx_bg_piece_move', 'game');
const actions = applyMove(game, move.piece.id, move.steps);
if (!actions) { isAnimating = false; return; }
// Check for hit sound
if (actions.some(a => a.type === 'hit')) {
audio.play('sfx_bg_piece_hit', 'game');
}
if (actions.some(a => a.type === 'bear')) {
audio.play('sfx_bg_bear_off', 'game');
}
clearSelection();
isAnimating = false;
render();
updateUI();
// Check if game over
if (game.isOver) {
handleGameOver();
return;
}
// Check if more moves available
if (game.movesLeft.length === 0) {
endTurn();
} else {
// Auto-select if only one move possible
const remaining = getValidMoves(game);
if (remaining.length === 0) endTurn();
}
}
function endTurn() {
nextTurn(game);
render();
updateUI();
updateTurnIndicator();
if (params.mode === 'bot' && game.turn !== myColor) {
setTimeout(doBotTurn, 600);
} else if (params.mode === 'live') {
syncToServer();
}
}
// ===== DICE ROLLING =====
function onRollClick() {
if (game.dice || isRolling || isAnimating) return;
if (game.turn !== myColor && params.mode !== 'local-multi') return;
isRolling = true;
audio.play('sfx_bg_dice_roll', 'game');
createRollAnimation(
() => render(),
() => {
isRolling = false;
const result = rollDice(game);
// If no playable moves, skip turn
if (game.movesLeft.length === 0) {
render();
bus.emit('toast', { text: 'لا حركات متاحة!' });
setTimeout(endTurn, 1000);
return;
}
render();
updateUI();
// Auto-play if only one possible move sequence
const moves = getValidMoves(game);
if (moves.length === 1 && game.movesLeft.length === 1) {
setTimeout(() => {
selectedPiece = { id: moves[0].piece.id, from: moves[0].from, moves };
executeMove(moves[0].to);
}, 400);
}
}
);
}
// ===== DOUBLING CUBE =====
function onDoubleClick() {
if (!match.cube || !canDouble(match.cube, myColor)) return;
if (game.dice) return;
if (params.mode === 'bot') {
offerDouble(match.cube, myColor);
const myPips = getPipCount(game.state, myColor);
const oppPips = getPipCount(game.state, myColor === WHITE ? BLACK : WHITE);
if (shouldBotAccept(match.cube, oppPips, myPips, params.difficulty)) {
acceptDouble(match.cube);
bus.emit('toast', { text: 'البوت قبل المضاعفة!' });
} else {
const loser = declineDouble(match.cube);
bus.emit('toast', { text: 'البوت رفض! فزت بالجولة' });
const result = endGame(match, myColor, 1);
showGameResult(result);
return;
}
updateUI();
} else if (params.mode === 'live') {
offerDouble(match.cube, myColor);
syncToServer();
}
}
function onAcceptDouble() {
if (!match.cube) return;
acceptDouble(match.cube);
const offerUI = canvas?.closest('.bgg-container')?.querySelector('#double-offer');
if (offerUI) offerUI.style.display = 'none';
updateUI();
if (params.mode === 'live') syncToServer();
}
function onDeclineDouble() {
if (!match.cube) return;
declineDouble(match.cube);
const offerUI = canvas?.closest('.bgg-container')?.querySelector('#double-offer');
if (offerUI) offerUI.style.display = 'none';
const result = endGame(match, game.turn, 1);
showGameResult(result);
}
// ===== BOT =====
function doBotTurn() {
if (game.turn === myColor || game.isOver) return;
// Bot considers doubling
if (match.cube && canUseCube(match) && canDouble(match.cube, game.turn) && !game.dice) {
const botPips = getPipCount(game.state, game.turn);
const myPips = getPipCount(game.state, myColor);
if (shouldBotDouble(match.cube, botPips, myPips, params.difficulty)) {
offerDouble(match.cube, game.turn);
const offerUI = canvas?.closest('.bgg-container')?.querySelector('#double-offer');
const valEl = canvas?.closest('.bgg-container')?.querySelector('#double-val');
if (offerUI) offerUI.style.display = '';
if (valEl) valEl.textContent = match.cube.value * 2;
audio.play('sfx_bg_double', 'game');
return;
}
}
// Roll dice
audio.play('sfx_bg_dice_roll', 'game');
isRolling = true;
setTimeout(() => {
isRolling = false;
rollDice(game);
render();
if (game.movesLeft.length === 0) {
setTimeout(endTurn, 600);
return;
}
// Make moves one by one
makeBotMoves(0);
}, 500);
}
function makeBotMoves(moveIndex) {
if (game.movesLeft.length === 0 || game.isOver) {
if (!game.isOver) endTurn();
else handleGameOver();
return;
}
const move = getBotMove(game, params.difficulty);
if (!move) {
endTurn();
return;
}
setTimeout(() => {
audio.play('sfx_bg_piece_move', 'game');
const actions = applyMove(game, move.piece.id, move.steps);
if (actions && actions.some(a => a.type === 'hit')) {
audio.play('sfx_bg_piece_hit', 'game');
}
render();
updateUI();
if (game.isOver) {
handleGameOver();
} else {
makeBotMoves(moveIndex + 1);
}
}, 400);
}
// ===== GAME OVER =====
function handleGameOver() {
const result = endGame(match, game.winner, game.score);
audio.play(game.winner === myColor ? 'sfx_bg_win_game' : 'lose', 'game');
showGameResult(result);
}
function showGameResult(result) {
updateUI();
if (result.matchOver) {
setTimeout(() => {
if (params.mode === 'live') syncComplete(result);
scene.replace('backgammon-result', {
winner: result.winner === myColor ? 'you' : 'opponent',
scores: result.scores,
matchLength: match.length,
mode: params.mode
});
}, 1500);
} else {
const who = result.winner === myColor ? 'فزت' : 'البوت فاز';
const scoreLabel = result.gameScore === 3 ? 'باكگمون!' : result.gameScore === 2 ? 'گامون!' : '';
bus.emit('toast', { text: `${who} بالجولة ${scoreLabel} (+${result.stake})` });
setTimeout(startNewGameRound, 2000);
}
}
function endMatchWithWin() {
scene.replace('backgammon-result', {
winner: 'you',
scores: match.scores,
matchLength: match.length,
mode: params.mode,
reason: 'abandon'
});
}
// ===== MULTIPLAYER SYNC =====
function syncToServer() {
if (params.mode !== 'live') return;
net.post('backgammon-match.php', {
action: 'move',
match_id: params.matchId,
game_state: serializeState(game),
match_state: getMatchState(match),
cube: match.cube
}).catch(() => {});
}
function syncComplete(result) {
net.post('backgammon-match.php', {
action: 'complete',
match_id: params.matchId,
winner: result.winner === myColor ? store.get('auth.userId') : 'opponent',
reason: 'normal'
}).catch(() => {});
}
function handleServerState(data) {
if (!data || data.error) return;
matchSession.markOpponentActive();
if (data.game_state) {
Object.assign(game, data.game_state);
if (data.match_state) {
match.scores = data.match_state.scores || match.scores;
}
if (data.cube) match.cube = data.cube;
render();
updateUI();
updateTurnIndicator();
}
}
// ===== EMOTE PANEL =====
function setupEmotePanel(el) {
const panel = el.querySelector('#emote-panel');
const emotes = ['🎲', '😤', '🤔', '👏', '😂', '🔥', '💀', '🫡'];
const phrases = ['!لعبة حلوة', '!حركة ممتازة', '!حظ', '...فكّر أسرع', '?ريماتش', 'gg wp'];
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 = () => {
audio.play('sfx_emote');
showEmoteBubble(btn.textContent, 'bottom');
panel.style.display = 'none';
if (params.mode === 'live') {
net.post('backgammon-match.php', { action: 'emote', match_id: params.matchId, emote: btn.textContent }).catch(() => {});
}
};
});
panel.querySelectorAll('.bgg-phrase-btn').forEach(btn => {
btn.onclick = () => {
audio.play('sfx_emote');
showPhraseBubble(btn.textContent, 'bottom');
panel.style.display = 'none';
};
});
}
function toggleEmotePanel() {
const panel = canvas?.closest('.bgg-container')?.querySelector('#emote-panel');
if (panel) panel.style.display = panel.style.display === 'none' ? '' : 'none';
}
function showEmoteBubble(emoteText, position) {
const container = canvas?.closest('.bgg-container');
if (!container) return;
const bubble = document.createElement('div');
bubble.className = `bgg-emote-bubble bgg-emote-${position}`;
bubble.textContent = emoteText;
container.appendChild(bubble);
setTimeout(() => bubble.remove(), 2000);
}
function showPhraseBubble(text, position) {
const container = canvas?.closest('.bgg-container');
if (!container) return;
const bubble = document.createElement('div');
bubble.className = `bgg-phrase-bubble bgg-phrase-${position}`;
bubble.textContent = text;
container.appendChild(bubble);
setTimeout(() => bubble.remove(), 3000);
}
function onQuit() {
audio.play('click');
if (params.mode === 'live') {
net.post('backgammon-match.php', { action: 'leave', match_id: params.matchId }).catch(() => {});
matchSession.destroy();
}
scene.exitGameMode();
scene.replace('backgammon-room', { mode: 'menu' });
}
function getGameStyles() {
return `<style>
.bgg-container {
position:relative;display:flex;flex-direction:column;
height:100%;background:#0a0f14;color:#f8fafc;overflow:hidden;
}
.bgg-top-bar,.bgg-bottom-bar {
display:flex;align-items:center;padding:8px 12px;gap:8px;
background:rgba(0,0,0,0.3);
}
.bgg-bottom-bar { justify-content:space-between; }
.bgg-player-card {
display:flex;align-items:center;gap:8px;flex:1;
}
.bgg-avatar {
width:36px;height:36px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:rgba(255,255,255,0.06);font-size:18px;
border:2px solid rgba(255,255,255,0.1);
}
.bgg-player-info { flex:1; }
.bgg-name { font-size:13px;font-weight:700; }
.bgg-pips { font-size:11px;color:#94a3b8; }
.bgg-score {
font-size:20px;font-weight:800;color:#d4940a;
min-width:24px;text-align:center;
}
.bgg-board-area {
flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
position:relative;padding:4px;
}
#bg-canvas { border-radius:8px;touch-action:none; }
.bgg-dice-area {
position:absolute;bottom:12px;left:50%;transform:translateX(-50%);
}
.bgg-roll-btn {
display:flex;align-items:center;gap:6px;
padding:12px 24px;border-radius:14px;border:none;
background:linear-gradient(135deg,#d4940a,#8B4513);
color:#fff;font-size:15px;font-weight:700;cursor:pointer;
box-shadow:0 4px 16px rgba(212,148,10,0.4);
animation:bgRollPulse 2s ease-in-out infinite;
}
.bgg-roll-btn:active { transform:scale(0.93); }
.bgg-cube-area { position:absolute;top:12px;right:12px; }
.bgg-cube-btn {
padding:8px 14px;border-radius:10px;border:none;
background:rgba(139,92,246,0.2);border:1px solid rgba(139,92,246,0.5);
color:#a78bfa;font-size:12px;font-weight:700;cursor:pointer;
}
.bgg-double-offer {
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
background:rgba(0,0,0,0.9);border:1px solid #d4940a;border-radius:16px;
padding:20px;text-align:center;z-index:10;
}
.bgg-double-offer p { margin:0 0 12px;font-size:14px; }
.bgg-accept-btn,.bgg-decline-btn {
padding:10px 20px;border-radius:10px;border:none;font-weight:700;cursor:pointer;margin:0 4px;
}
.bgg-accept-btn { background:#10b981;color:#fff; }
.bgg-decline-btn { background:#ef4444;color:#fff; }
.bgg-actions { display:flex;gap:6px; }
.bgg-action-btn {
width:36px;height:36px;border-radius:50%;border:none;
background:rgba(255,255,255,0.06);color:#94a3b8;
font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;
}
.bgg-action-btn:active { background:rgba(255,255,255,0.12); }
.bgg-action-quit { color:#ef4444; }
.bgg-turn-indicator {
position:absolute;top:50%;left:4px;transform:translateY(-50%);
writing-mode:vertical-rl;font-size:10px;font-weight:700;
padding:6px 4px;border-radius:6px;
}
.bgg-my-turn { background:rgba(16,185,129,0.2);color:#10b981; }
.bgg-opp-turn { background:rgba(239,68,68,0.15);color:#f87171; }
.bgg-emote-panel {
position:absolute;bottom:60px;right:12px;
background:rgba(10,15,20,0.95);border:1px solid rgba(255,255,255,0.1);
border-radius:14px;padding:12px;z-index:20;max-width:260px;
}
.bgg-emotes { display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px; }
.bgg-emote-btn {
width:36px;height:36px;border-radius:10px;border:none;
background:rgba(255,255,255,0.06);font-size:20px;cursor:pointer;
}
.bgg-emote-btn:active { background:rgba(255,255,255,0.15); }
.bgg-phrases { display:flex;flex-direction:column;gap:4px; }
.bgg-phrase-btn {
padding:6px 12px;border-radius:8px;border:none;
background:rgba(255,255,255,0.04);color:#94a3b8;
font-size:12px;cursor:pointer;text-align:right;
}
.bgg-phrase-btn:active { background:rgba(255,255,255,0.1); }
.bgg-emote-bubble {
position:absolute;font-size:40px;
animation:bgEmoteFloat 2s ease-out forwards;pointer-events:none;
}
.bgg-emote-bottom { bottom:70px;right:50px; }
.bgg-emote-top { top:70px;left:50px; }
.bgg-phrase-bubble {
position:absolute;padding:8px 14px;border-radius:12px;
background:rgba(212,148,10,0.15);border:1px solid rgba(212,148,10,0.3);
color:#d4940a;font-size:13px;font-weight:600;
animation:bgPhraseFloat 3s ease-out forwards;pointer-events:none;
}
.bgg-phrase-bottom { bottom:70px;right:20px; }
.bgg-phrase-top { top:70px;left:20px; }
@keyframes bgRollPulse { 0%,100%{box-shadow:0 4px 16px rgba(212,148,10,0.4)} 50%{box-shadow:0 4px 24px rgba(212,148,10,0.7)} }
@keyframes bgEmoteFloat { 0%{opacity:1;transform:translateY(0) scale(1)} 100%{opacity:0;transform:translateY(-60px) scale(1.3)} }
@keyframes bgPhraseFloat { 0%{opacity:1;transform:translateY(0)} 80%{opacity:1} 100%{opacity:0;transform:translateY(-30px)} }
</style>`;
}
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>`;
}
import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import { emoji } from '../../../core/theme.js';
export function mountRoom(el, params) {
const { mode = 'menu' } = params || {};
if (mode === 'menu') renderMenu(el);
else if (mode === 'setup') renderSetup(el, params);
else if (mode === 'searching') renderSearching(el, params);
}
function renderMenu(el) {
el.innerHTML = `
<div class="bg-wrap">
<div class="bg-hero">
<div class="bg-icon">${emoji('game_die', '🎲', 56)}</div>
<h1 class="bg-title">طاولة</h1>
<p class="bg-subtitle">اول من يطلّع كل قطعه يفوز!</p>
</div>
<div class="bg-buttons">
<button class="bg-btn bg-btn-primary" id="btn-local">
<span class="bg-btn-icon">${emoji('gamepad', '🎮', 22)}</span>
<span class="bg-btn-label">ضد البوت</span>
<span class="bg-btn-desc">اختر المستوى والنوع</span>
</button>
<button class="bg-btn bg-btn-online" id="btn-online">
<span class="bg-btn-icon">${emoji('globe', '🌍', 22)}</span>
<span class="bg-btn-label">أونلاين</span>
<span class="bg-btn-desc">العب ضد لاعبين حقيقيين</span>
</button>
<button class="bg-btn bg-btn-friend" id="btn-friend">
<span class="bg-btn-icon">${emoji('handshake', '🤝', 20)}</span>
<span class="bg-btn-label">تحدي صديق</span>
<span class="bg-btn-desc">ادعُ صديقك للعب</span>
</button>
</div>
<button class="bg-back" id="btn-back">رجوع</button>
</div>
${getStyles()}
`;
el.querySelector('#btn-local').onclick = () => {
audio.play('click');
scene.replace('backgammon-room', { mode: 'setup', type: 'local' });
};
el.querySelector('#btn-online').onclick = () => {
audio.play('click');
if (store.get('auth.isGuest')) {
bus.emit('toast', { text: 'سجّل دخولك للعب أونلاين' });
return;
}
scene.replace('backgammon-room', { mode: 'setup', type: 'online' });
};
el.querySelector('#btn-friend').onclick = () => {
audio.play('click');
if (store.get('auth.isGuest')) {
bus.emit('toast', { text: 'سجّل دخولك لتحدي صديق' });
return;
}
scene.push('challenge-friend', { game: 'backgammon' });
};
el.querySelector('#btn-back').onclick = () => {
audio.play('click');
bus.emit('navigate', { world: 'play', scene: 'play-table' });
};
}
function renderSetup(el, params) {
const { type = 'local' } = params;
const isOnline = type === 'online';
el.innerHTML = `
<div class="bg-wrap">
<div class="bg-hero" style="margin-bottom:16px;">
<h2 class="bg-title" style="font-size:20px;">${isOnline ? 'إعدادات الأونلاين' : 'إعدادات اللعبة'}</h2>
</div>
<!-- Variant -->
<div class="bg-section">
<div class="bg-section-title">نوع اللعبة</div>
<div class="bg-grid" id="variant-grid">
<button class="bg-chip bg-chip-active" data-variant="standard">
<span class="bg-chip-label">عادية</span>
</button>
<button class="bg-chip" data-variant="gulbara">
<span class="bg-chip-label">گلبرة</span>
</button>
<button class="bg-chip" data-variant="tapa">
<span class="bg-chip-label">طاپا</span>
</button>
</div>
</div>
<!-- Match Length -->
<div class="bg-section">
<div class="bg-section-title">طول الماتش</div>
<div class="bg-grid" id="length-grid">
<button class="bg-chip" data-len="1">
<span class="bg-chip-num">1</span>
<span class="bg-chip-label">سريع</span>
</button>
<button class="bg-chip bg-chip-active" data-len="3">
<span class="bg-chip-num">3</span>
<span class="bg-chip-label">قصير</span>
</button>
<button class="bg-chip" data-len="5">
<span class="bg-chip-num">5</span>
<span class="bg-chip-label">عادي</span>
</button>
<button class="bg-chip" data-len="7">
<span class="bg-chip-num">7</span>
<span class="bg-chip-label">طويل</span>
</button>
</div>
</div>
${!isOnline ? `
<!-- Bot Difficulty -->
<div class="bg-section">
<div class="bg-section-title">مستوى البوت</div>
<div class="bg-grid" id="difficulty-grid">
<button class="bg-chip" data-diff="easy">
<span>😊</span>
<span class="bg-chip-label">سهل</span>
</button>
<button class="bg-chip bg-chip-active" data-diff="medium">
<span>🧐</span>
<span class="bg-chip-label">متوسط</span>
</button>
<button class="bg-chip" data-diff="hard">
<span>🧠</span>
<span class="bg-chip-label">صعب</span>
</button>
</div>
</div>
` : ''}
<!-- Doubling Cube Toggle -->
<div class="bg-section">
<div class="bg-section-title">مكعب المضاعفة</div>
<div class="bg-grid" id="cube-grid">
<button class="bg-chip bg-chip-active" data-cube="on">
<span class="bg-chip-label">مفعّل</span>
</button>
<button class="bg-chip" data-cube="off">
<span class="bg-chip-label">بدون</span>
</button>
</div>
</div>
<button class="bg-btn bg-btn-start" id="btn-start">
${isOnline ? 'ابحث عن مباراة' : 'ابدأ اللعب'}
</button>
<button class="bg-back" id="btn-back-setup">رجوع</button>
</div>
${getStyles()}
`;
let variant = 'standard';
let matchLength = 3;
let difficulty = 'medium';
let useCube = true;
setupChipGroup(el, '#variant-grid', val => { variant = val; }, 'variant');
setupChipGroup(el, '#length-grid', val => { matchLength = parseInt(val); }, 'len');
if (!isOnline) setupChipGroup(el, '#difficulty-grid', val => { difficulty = val; }, 'diff');
setupChipGroup(el, '#cube-grid', val => { useCube = val === 'on'; }, 'cube');
el.querySelector('#btn-start').onclick = () => {
audio.play('click');
if (isOnline) {
scene.push('play-queue', { game: 'backgammon', variant, matchLength, useCube });
} else {
scene.enterGameMode();
scene.replace('backgammon-game', {
mode: 'bot',
variant,
matchLength,
difficulty,
useCube
});
}
};
el.querySelector('#btn-back-setup').onclick = () => {
audio.play('click');
scene.replace('backgammon-room', { mode: 'menu' });
};
}
function renderSearching(el) {
el.innerHTML = `
<div class="bg-wrap">
<div class="bg-hero">
<div class="bg-pulse-ring">
<div class="bg-pulse-inner">${emoji('game_die', '🎲', 32)}</div>
</div>
<h2 class="bg-title" style="font-size:18px;margin-top:20px;">جاري البحث...</h2>
<p class="bg-subtitle">بنوصّلك بلاعب قريب</p>
</div>
<button class="bg-btn bg-btn-friend" id="btn-cancel" style="max-width:200px;">إلغاء</button>
</div>
${getStyles()}
`;
el.querySelector('#btn-cancel').onclick = () => { audio.play('click'); scene.pop(); };
}
function setupChipGroup(el, selector, onChange, dataKey) {
el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll(`${selector} .bg-chip`).forEach(b => b.classList.remove('bg-chip-active'));
btn.classList.add('bg-chip-active');
onChange(btn.dataset[dataKey]);
});
});
}
function getStyles() {
return `<style>
.bg-wrap {
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:20px;
background:linear-gradient(180deg,#0a0f14 0%,#101820 40%,#141f2c 100%);
overflow-y:auto;
}
.bg-hero { text-align:center;margin-bottom:24px; }
.bg-icon { font-size:56px;margin-bottom:12px;animation:bgFloat 3s ease-in-out infinite; }
.bg-title { font-size:26px;font-weight:800;color:#f8fafc;margin:0; }
.bg-subtitle { font-size:13px;color:#d4940a;margin:8px 0 0;opacity:0.9; }
.bg-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:320px; }
.bg-btn {
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
min-height:56px;border-radius:16px;font-size:16px;font-weight:700;
border:none;cursor:pointer;padding:14px 18px;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.2s;
box-shadow:0 4px 16px rgba(0,0,0,0.3);
}
.bg-btn:active { transform:scale(0.95); }
.bg-btn-primary { background:linear-gradient(135deg,#d4940a 0%,#8B4513 100%);color:#fff; }
.bg-btn-online { background:linear-gradient(135deg,#8B5CF6 0%,#7C3AED 100%);color:#fff; }
.bg-btn-friend { background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);color:#e2e8f0;min-height:50px;font-size:14px;box-shadow:none; }
.bg-btn-start { width:100%;max-width:320px;justify-content:center;background:linear-gradient(135deg,#d4940a,#8B4513);color:#fff;font-size:17px;font-weight:800;margin-top:16px; }
.bg-btn-icon { display:flex;flex-shrink:0; }
.bg-btn-label { font-weight:700;font-size:16px; }
.bg-btn-desc { width:100%;font-size:11px;opacity:0.7;font-weight:400;margin-top:2px; }
.bg-back { margin-top:16px;font-size:13px;color:#64748b;background:none;border:none;cursor:pointer;padding:8px 16px; }
.bg-section { width:100%;max-width:320px;margin-bottom:16px; }
.bg-section-title { font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:8px;text-align:center; }
.bg-grid { display:flex;gap:8px;justify-content:center;flex-wrap:wrap; }
.bg-chip {
display:flex;align-items:center;gap:6px;
padding:10px 16px;border-radius:12px;
background:rgba(255,255,255,0.04);border:1.5px solid rgba(255,255,255,0.08);
color:#94a3b8;font-size:13px;font-weight:600;cursor:pointer;
transition:all 0.2s cubic-bezier(0.34,1.56,0.64,1);
}
.bg-chip:active { transform:scale(0.95); }
.bg-chip-active { background:rgba(212,148,10,0.12);border-color:rgba(212,148,10,0.5);color:#d4940a; }
.bg-chip-num { font-size:18px;font-weight:800; }
.bg-chip-label { font-size:12px; }
.bg-pulse-ring {
width:80px;height:80px;border-radius:50%;
border:3px solid rgba(212,148,10,0.4);
display:flex;align-items:center;justify-content:center;
animation:bgPulse 2s ease-in-out infinite;margin:0 auto;
}
.bg-pulse-inner { font-size:32px; }
@keyframes bgFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
@keyframes bgPulse { 0%,100%{border-color:rgba(212,148,10,0.4);transform:scale(1)} 50%{border-color:rgba(212,148,10,0.7);transform:scale(1.05)} }
</style>`;
}
export function unmountRoom() {}
// 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) { ...@@ -45,12 +45,14 @@ export function mountQueue(el, params) {
} }
async function joinQueue(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 { try {
const data = await net.post(endpoint, { const data = await net.post(endpoint, {
action: 'queue', action: 'queue',
game_key: params.game, 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) { if (data.match_id) {
...@@ -64,7 +66,7 @@ async function joinQueue(params) { ...@@ -64,7 +66,7 @@ async function joinQueue(params) {
} }
function pollForMatch(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 () => { unsub = setInterval(async () => {
try { try {
const data = await net.post(endpoint, { action: 'status', game_key: params.game }); const data = await net.post(endpoint, { action: 'status', game_key: params.game });
......
...@@ -56,7 +56,7 @@ export function mountTable(el) { ...@@ -56,7 +56,7 @@ export function mountTable(el) {
<!-- Games --> <!-- Games -->
<div class="games-grid"> <div class="games-grid">
${games.map(g => { ${games.map(g => {
const disabled = g.key === 'backgammon'; const disabled = false;
return ` 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 ${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> <div class="game-tile-bg" style="background:${g.gradient};"></div>
...@@ -360,6 +360,8 @@ function showGameMenu(menu, game) { ...@@ -360,6 +360,8 @@ function showGameMenu(menu, game) {
scene.push('domino-room', { mode: 'bot-pick' }); scene.push('domino-room', { mode: 'bot-pick' });
} else if (game.key === 'ludo') { } else if (game.key === 'ludo') {
scene.push('ludo-room', { mode: 'setup', type: 'local' }); scene.push('ludo-room', { mode: 'setup', type: 'local' });
} else if (game.key === 'backgammon') {
scene.push('backgammon-room', { mode: 'menu' });
} else { } else {
const gameScene = game.key + '-game'; const gameScene = game.key + '-game';
scene.push(gameScene, { mode: 'bot', game: game.key }); scene.push(gameScene, { mode: 'bot', game: game.key });
...@@ -384,6 +386,8 @@ function showGameMenu(menu, game) { ...@@ -384,6 +386,8 @@ function showGameMenu(menu, game) {
scene.push('play-time-select', { game: game.key, mode: 'human' }); scene.push('play-time-select', { game: game.key, mode: 'human' });
} else if (game.key === 'ludo') { } else if (game.key === 'ludo') {
scene.push('ludo-room', { mode: 'setup', type: 'online' }); scene.push('ludo-room', { mode: 'setup', type: 'online' });
} else if (game.key === 'backgammon') {
scene.push('backgammon-room', { mode: 'setup', type: 'online' });
} else { } else {
scene.push('play-queue', { game: game.key, mode: 'human', timeControl: 'standard' }); 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