Commit 4cf9baa5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: full backgammon overhaul — 60fps game loop, premium canvas, particles, admin branding

- 60fps requestAnimationFrame game loop with dt-based particle physics
- 3D checkers with radial gradients, specular highlights, drop shadows
- Wood-grain board texture with gradient triangles and bevel frame
- Physics-based dice with bounce damping, spin, and staggered animation
- Arc-based move animations with easeOutBack overshoot
- Particle burst on hit (red/orange sparks) and bear-off (green stars)
- Confetti celebration on match win with canvas animation
- Full multiplayer sync via match-session + server polling
- Bot AI with animated moves (arc + SFX per piece)
- Admin branding: 14 board colors + 7 game settings (durations, particles)
- CSS tokens for all backgammon board colors (--bg-*)
- Theme.js maps branding values to CSS vars + window.__EL3AB_THEME
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent fe69312e
......@@ -365,6 +365,68 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
</div>
</div>
<!-- BACKGAMMON BOARD -->
<h2>🎲 ألوان لوحة الطاولة (Backgammon)</h2>
<div class="section">
<div class="grid">
<?php
$bgColors = [
['key' => 'bg_board_border', 'label' => 'إطار اللوح', 'default' => '#5C3A0E', 'hint' => 'الحافة الخارجية للبورد'],
['key' => 'bg_board_light', 'label' => 'سطح اللوح فاتح', 'default' => '#3D6B1E', 'hint' => 'تدرج سطح اللوحة — فاتح'],
['key' => 'bg_board_dark', 'label' => 'سطح اللوح غامق', 'default' => '#2D5016', 'hint' => 'تدرج سطح اللوحة — غامق'],
['key' => 'bg_bar_dark', 'label' => 'البار — غامق', 'default' => '#2A1800', 'hint' => 'الشريط الوسطي'],
['key' => 'bg_bar_light', 'label' => 'البار — فاتح', 'default' => '#3D2B1A', 'hint' => 'منتصف شريط البار'],
['key' => 'bg_bearoff', 'label' => 'منطقة التطليع', 'default' => '#1A0E00', 'hint' => 'Bear-off tray'],
['key' => 'bg_point_dark', 'label' => 'مثلث غامق', 'default' => '#8B4513', 'hint' => 'أطراف المثلثات الداكنة'],
['key' => 'bg_point_dark_tip', 'label' => 'رأس مثلث غامق', 'default' => '#5C2D0A', 'hint' => 'رأس المثلث الداكن (تدرج)'],
['key' => 'bg_point_light', 'label' => 'مثلث فاتح', 'default' => '#D2691E', 'hint' => 'أطراف المثلثات الفاتحة'],
['key' => 'bg_point_light_tip', 'label' => 'رأس مثلث فاتح', 'default' => '#A0522D', 'hint' => 'رأس المثلث الفاتح (تدرج)'],
['key' => 'bg_checker_white', 'label' => 'قطعة بيضاء', 'default' => '#F5E6D3', 'hint' => 'لون القطع البيضاء'],
['key' => 'bg_checker_black', 'label' => 'قطعة سوداء', 'default' => '#2A2A2A', 'hint' => 'لون القطع السوداء'],
['key' => 'bg_highlight', 'label' => 'تمييز الحركة', 'default' => '#10B981', 'hint' => 'لون الأماكن المتاحة'],
['key' => 'bg_selected', 'label' => 'القطعة المختارة', 'default' => '#FFD700', 'hint' => 'توهج القطعة المختارة'],
];
foreach ($bgColors as $c):
$val = $theme[$c['key']] ?? $c['default'];
?>
<div class="field">
<label><?= $c['label'] ?></label>
<div class="color-row">
<input type="color" name="theme[<?= $c['key'] ?>]" value="<?= $val ?>">
<input type="text" name="theme[<?= $c['key'] ?>]" value="<?= $val ?>">
</div>
<div class="hint"><?= $c['hint'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- BACKGAMMON SETTINGS -->
<h2>🎲 إعدادات الطاولة</h2>
<div class="section">
<div class="grid">
<?php
$bgSettings = [
['key' => 'bg_move_duration', 'label' => 'مدة حركة القطعة (ms)', 'default' => '320', 'hint' => 'سرعة انتقال القطعة (300-500)', 'type' => 'number'],
['key' => 'bg_dice_duration', 'label' => 'مدة رمي النرد (ms)', 'default' => '700', 'hint' => 'مدة أنيميشن النرد (500-900)', 'type' => 'number'],
['key' => 'bg_bot_delay', 'label' => 'تأخير البوت (ms)', 'default' => '700', 'hint' => 'تأخير قبل حركة البوت', 'type' => 'number'],
['key' => 'bg_particle_count', 'label' => 'عدد جسيمات الضرب', 'default' => '12', 'hint' => 'جسيمات عند ضرب قطعة (0=إلغاء)', 'type' => 'number'],
['key' => 'bg_arc_height', 'label' => 'ارتفاع قوس الحركة', 'default' => '0.4', 'hint' => 'نسبة ارتفاع القوس (0-1)', 'type' => 'text'],
['key' => 'bg_turn_timeout', 'label' => 'مهلة الدور (ثانية)', 'default' => '30', 'hint' => 'المهلة للعب أونلاين', 'type' => 'number'],
['key' => 'bg_confetti_count', 'label' => 'عدد جسيمات الفوز', 'default' => '30', 'hint' => 'عدد الكونفيتي عند الفوز', 'type' => 'number'],
];
foreach ($bgSettings as $s):
$val = $theme[$s['key']] ?? $s['default'];
?>
<div class="field">
<label><?= $s['label'] ?></label>
<input type="<?= $s['type'] ?>" name="theme[<?= $s['key'] ?>]" value="<?= $val ?>">
<div class="hint"><?= $s['hint'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- ANIMATIONS -->
<h2>⚡ الحركة والرسوم المتحركة</h2>
<div class="section">
......
......@@ -25,6 +25,21 @@
--backgammon-primary: #F59E0B;
--backgammon-secondary: #EF4444;
--bg-board-border: #5C3A0E;
--bg-board-light: #3D6B1E;
--bg-board-dark: #2D5016;
--bg-bar-dark: #2A1800;
--bg-bar-light: #3D2B1A;
--bg-bearoff: #1A0E00;
--bg-point-dark: #8B4513;
--bg-point-dark-tip: #5C2D0A;
--bg-point-light: #D2691E;
--bg-point-light-tip: #A0522D;
--bg-checker-white: #F5E6D3;
--bg-checker-black: #2A2A2A;
--bg-highlight: #10B981;
--bg-selected: #FFD700;
--text-primary: #F8FAFC;
--text-secondary: #94A3B8;
--text-muted: #475569;
......
......@@ -73,6 +73,20 @@ function applyColors() {
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
}
// Backgammon board colors
const bgMap = {
bg_board_border: '--bg-board-border', bg_board_light: '--bg-board-light',
bg_board_dark: '--bg-board-dark', bg_bar_dark: '--bg-bar-dark',
bg_bar_light: '--bg-bar-light', bg_bearoff: '--bg-bearoff',
bg_point_dark: '--bg-point-dark', bg_point_dark_tip: '--bg-point-dark-tip',
bg_point_light: '--bg-point-light', bg_point_light_tip: '--bg-point-light-tip',
bg_checker_white: '--bg-checker-white', bg_checker_black: '--bg-checker-black',
bg_highlight: '--bg-highlight', bg_selected: '--bg-selected'
};
for (const [key, cssVar] of Object.entries(bgMap)) {
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
}
// Also expose ALL theme values as a flat map for JS access
window.__EL3AB_THEME = themeData;
}
......
// Board renderer — draws the backgammon board on canvas
// Premium 60fps board renderer — wood grain, 3D checkers, glow, particles
import { WHITE, BLACK } from '../logic/rules.js';
import { getColor } from '../../../core/theme.js';
const CHECKER_RADIUS = 16;
const MAX_VISIBLE = 5;
const t = (k, fb) => getColor(k, null) || (window.__EL3AB_THEME?.[k]) || fb;
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 { highlights = [], selectedPoint = null, animFrame, particles = [] } = options;
const margin = 8;
const barWidth = w * 0.06;
const bearOffWidth = w * 0.07;
const barWidth = Math.round(w * 0.06);
const bearW = Math.round(w * 0.07);
const boardW = w - margin * 2;
const boardH = h - margin * 2;
const innerW = boardW - bearOffWidth;
const innerH = boardH;
const innerW = boardW - bearW;
const pointW = (innerW - barWidth) / 12;
const pointH = innerH * 0.42;
const pointH = boardH * 0.42;
const checkerR = Math.min(pointW * 0.43, 17);
const layout = { margin, pointW, pointH, barWidth, boardW, boardH, bearW, innerW, checkerR, w, h };
const barX = margin + 6 * pointW;
ctx.clearRect(0, 0, w, h);
// Board background
ctx.fillStyle = boardColor;
ctx.fillRect(margin, margin, boardW, boardH);
// ── Board frame ──
ctx.fillStyle = t('bg_board_border', '#5C3A0E');
roundRect(ctx, margin - 3, margin - 3, boardW + 6, boardH + 6, 6);
ctx.fill();
// Border
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(margin, margin, boardW, boardH);
// ── Board surface (wood grain) ──
const woodGrad = ctx.createLinearGradient(margin, margin, margin, margin + boardH);
woodGrad.addColorStop(0, t('bg_board_light', '#3D6B1E'));
woodGrad.addColorStop(0.5, t('bg_board_dark', '#2D5016'));
woodGrad.addColorStop(1, t('bg_board_light', '#3D6B1E'));
ctx.fillStyle = woodGrad;
ctx.fillRect(margin, margin, boardW, boardH);
// Bar
const barX = margin + 6 * pointW;
ctx.fillStyle = barColor;
// Wood grain lines
ctx.globalAlpha = 0.06;
for (let i = 0; i < 30; i++) {
const gy = margin + (boardH / 30) * i + Math.sin(i * 0.7) * 3;
ctx.strokeStyle = '#000';
ctx.lineWidth = 0.5 + Math.random() * 0.5;
ctx.beginPath();
ctx.moveTo(margin, gy);
ctx.bezierCurveTo(margin + boardW * 0.3, gy + 2, margin + boardW * 0.7, gy - 2, margin + boardW, gy);
ctx.stroke();
}
ctx.globalAlpha = 1;
// ── Bar ──
const barGrad = ctx.createLinearGradient(barX, 0, barX + barWidth, 0);
barGrad.addColorStop(0, t('bg_bar_dark', '#2A1800'));
barGrad.addColorStop(0.5, t('bg_bar_light', '#3D2B1A'));
barGrad.addColorStop(1, t('bg_bar_dark', '#2A1800'));
ctx.fillStyle = barGrad;
ctx.fillRect(barX, margin, barWidth, boardH);
// Bear-off tray
// Bar edge shadows
ctx.fillStyle = 'rgba(0,0,0,0.4)';
ctx.fillRect(barX, margin, 1.5, boardH);
ctx.fillRect(barX + barWidth - 1.5, margin, 1.5, 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);
ctx.fillStyle = t('bg_bearoff', '#1A0E00');
roundRect(ctx, bearX, margin, bearW, boardH, 3);
ctx.fill();
ctx.strokeStyle = 'rgba(139,69,19,0.4)';
ctx.lineWidth = 1;
roundRect(ctx, bearX, margin, bearW, boardH, 3);
ctx.stroke();
// Draw points (triangles)
// Bear-off divider
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.moveTo(bearX, margin + boardH / 2);
ctx.lineTo(bearX + bearW, margin + boardH / 2);
ctx.stroke();
// ── 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;
const isDark = i % 2 === 0;
const isValid = highlights.includes(i);
const isSel = selectedPoint === i;
ctx.beginPath();
if (isTop) {
......@@ -63,200 +97,299 @@ export function drawBoard(ctx, w, h, state, options = {}) {
}
ctx.closePath();
// Highlight valid destinations
if (highlights.includes(i)) {
ctx.fillStyle = 'rgba(34, 197, 94, 0.4)';
if (isSel) {
ctx.fillStyle = 'rgba(255, 215, 0, 0.35)';
ctx.fill();
ctx.strokeStyle = '#22c55e';
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.stroke();
} else if (selectedPoint === i) {
ctx.fillStyle = 'rgba(251, 191, 36, 0.3)';
} else if (isValid) {
ctx.fillStyle = 'rgba(16, 185, 129, 0.35)';
ctx.fill();
ctx.strokeStyle = '#fbbf24';
ctx.lineWidth = 2;
ctx.strokeStyle = '#10B981';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#10B981';
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
} else {
ctx.fillStyle = color;
const ptGrad = ctx.createLinearGradient(x, isTop ? y : y - pointH, x, isTop ? y + pointH : y);
if (isDark) {
ptGrad.addColorStop(0, t('bg_point_dark', '#8B4513'));
ptGrad.addColorStop(1, t('bg_point_dark_tip', '#5C2D0A'));
} else {
ptGrad.addColorStop(0, t('bg_point_light', '#D2691E'));
ptGrad.addColorStop(1, t('bg_point_light_tip', '#A0522D'));
}
ctx.fillStyle = ptGrad;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.2)';
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
// Draw checkers on points
// ── Point numbers ──
ctx.font = `bold ${Math.max(8, pointW * 0.32)}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(255,255,255,0.15)';
for (let i = 0; i < 24; i++) {
const { x, y, isTop } = getPointCoords(i, margin, pointW, pointH, barWidth, boardH);
const ny = isTop ? y - 6 : y + 10;
ctx.fillText(String(i + 1), x + pointW / 2, ny);
}
// ── Checkers ──
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 maxVis = 5;
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);
const vis = Math.min(count, maxVis);
const spacing = Math.min(checkerR * 2, pointH / vis);
for (let j = 0; j < vis; j++) {
const cy = isTop ? y + checkerR + j * spacing : y - checkerR - j * spacing;
const isTopChecker = j === vis - 1;
const glowing = isTopChecker && isSel === i;
drawChecker(ctx, cx, cy, pieces[j].type, checkerR, {
glow: isSel && isTopChecker,
pulse: isValid(i) && isTopChecker
});
}
if (count > MAX_VISIBLE) {
const lastY = isTop
? y + CHECKER_RADIUS + (visibleCount - 1) * spacing
: y - CHECKER_RADIUS - (visibleCount - 1) * spacing;
if (count > maxVis) {
const ly = isTop ? y + checkerR + (vis - 1) * spacing : y - checkerR - (vis - 1) * spacing;
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px sans-serif';
ctx.font = `bold ${checkerR * 0.65}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, lastY);
ctx.fillText(String(count), cx, ly);
}
}
// Draw bar pieces
drawBarPieces(ctx, state, barX, barWidth, margin, boardH, theme);
// ── Bar checkers ──
const barCx = barX + barWidth / 2;
drawBarCheckers(ctx, state.bar[WHITE], barCx, margin + boardH * 0.65, checkerR, WHITE, 1);
drawBarCheckers(ctx, state.bar[BLACK], barCx, margin + boardH * 0.35, checkerR, BLACK, -1);
// Draw bear-off pieces
drawBearOffPieces(ctx, state, bearX, bearOffWidth, margin, boardH, theme);
// ── Bear-off ──
drawBearOff(ctx, state.outside[WHITE], bearX, bearW, margin + boardH * 0.55, checkerR, WHITE, 1);
drawBearOff(ctx, state.outside[BLACK], bearX, bearW, margin + boardH * 0.45, checkerR, BLACK, -1);
// Highlight bear-off if valid destination
// Highlight bear-off if valid
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.fillStyle = 'rgba(16, 185, 129, 0.25)';
ctx.strokeStyle = '#10B981';
ctx.lineWidth = 2;
ctx.strokeRect(bearX + 2, margin + 2, bearOffWidth - 4, boardH - 4);
ctx.shadowColor = '#10B981';
ctx.shadowBlur = 6;
roundRect(ctx, bearX + 2, margin + 2, bearW - 4, boardH - 4, 3);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
}
return { margin, pointW, pointH, barWidth, barX, boardH, bearX, bearOffWidth, innerW };
// ── Particles ──
if (particles.length) drawParticles(ctx, particles);
function isValid(idx) { return highlights.includes(idx); }
function isSel(idx) { return selectedPoint === idx; }
return layout;
}
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';
function drawChecker(ctx, cx, cy, type, r, opts = {}) {
const isWhite = type === WHITE;
// Drop shadow
ctx.beginPath();
ctx.arc(x, y, CHECKER_RADIUS - 1, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.arc(cx + 1, cy + 2, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.35)';
ctx.fill();
ctx.strokeStyle = borderCol;
ctx.lineWidth = 2;
// Base
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(cx - r * 0.3, cy - r * 0.3, r * 0.1, cx, cy, r);
if (isWhite) {
grad.addColorStop(0, '#FFFFFF');
grad.addColorStop(0.6, '#F5E6D3');
grad.addColorStop(1, '#C8A882');
} else {
grad.addColorStop(0, '#555555');
grad.addColorStop(0.6, '#2A2A2A');
grad.addColorStop(1, '#0A0A0A');
}
ctx.fillStyle = grad;
ctx.fill();
// Edge ring
ctx.strokeStyle = isWhite ? 'rgba(160,120,60,0.5)' : 'rgba(100,100,100,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Inner ring
// Inner decoration ring
ctx.beginPath();
ctx.arc(x, y, CHECKER_RADIUS - 5, 0, Math.PI * 2);
ctx.strokeStyle = type === WHITE ? '#cbd5e1' : '#64748b';
ctx.arc(cx, cy, r * 0.62, 0, Math.PI * 2);
ctx.strokeStyle = isWhite ? 'rgba(180,140,80,0.25)' : 'rgba(120,120,120,0.25)';
ctx.lineWidth = 1;
ctx.stroke();
}
function drawBarPieces(ctx, state, barX, barWidth, margin, boardH, theme) {
const cx = barX + barWidth / 2;
// Specular highlight
ctx.beginPath();
ctx.ellipse(cx - r * 0.2, cy - r * 0.25, r * 0.35, r * 0.2, -0.3, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${isWhite ? 0.3 : 0.12})`;
ctx.fill();
// Glow for selected
if (opts.glow) {
ctx.beginPath();
ctx.arc(cx, cy, r + 3, 0, Math.PI * 2);
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2.5;
ctx.shadowColor = '#FFD700';
ctx.shadowBlur = 12;
ctx.stroke();
ctx.shadowBlur = 0;
}
}
// 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);
function drawBarCheckers(ctx, pieces, cx, startY, r, type, dir) {
const count = pieces.length;
if (!count) return;
const vis = Math.min(count, 4);
for (let j = 0; j < vis; j++) {
drawChecker(ctx, cx, startY + dir * j * r * 1.9, type, r);
}
if (count > 4) {
ctx.fillStyle = '#fff';
ctx.font = `bold ${r * 0.7}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, startY + dir * vis * r * 1.9);
}
}
// 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 drawBearOff(ctx, pieces, bearX, bearW, startY, r, type, dir) {
const count = pieces.length;
if (!count) return;
const cx = bearX + bearW / 2;
const sliceH = Math.min(6, r * 0.4);
const vis = Math.min(count, 15);
const isWhite = type === WHITE;
for (let i = 0; i < vis; i++) {
const sy = startY + dir * i * sliceH;
ctx.fillStyle = isWhite ? '#F5E6D3' : '#2A2A2A';
ctx.fillRect(cx - r + 2, sy, (r - 2) * 2, sliceH - 1);
ctx.strokeStyle = isWhite ? 'rgba(160,120,60,0.3)' : 'rgba(80,80,80,0.3)';
ctx.lineWidth = 0.5;
ctx.strokeRect(cx - r + 2, sy, (r - 2) * 2, sliceH - 1);
}
ctx.fillStyle = '#fff';
ctx.font = `bold ${r * 0.7}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(count), cx, startY + dir * (vis * sliceH + r));
}
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);
function drawParticles(ctx, particles) {
for (const p of particles) {
if (p.alpha <= 0) continue;
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color || '#FFD700';
if (p.shape === 'star') {
drawStar(ctx, p.x, p.y, p.size, p.rot || 0);
} else {
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
// 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);
}
function drawStar(ctx, x, y, size, rotation) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const a = (i * 4 * Math.PI) / 5 - Math.PI / 2;
ctx[i === 0 ? 'moveTo' : 'lineTo'](Math.cos(a) * size, Math.sin(a) * size);
}
ctx.closePath();
ctx.fill();
ctx.restore();
}
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;
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout;
const barX = margin + 6 * pointW;
const bearX = margin + innerW;
// 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) {
if (canvasX >= bearX && canvasX <= bearX + bearW) {
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) {
if (isTop && canvasY >= margin && canvasY <= margin + pointH + checkerR * 2) {
return { type: 'point', index: i };
}
if (!isTop && canvasY >= margin + boardH - pointH - CHECKER_RADIUS * 2 && canvasY <= margin + boardH) {
if (!isTop && canvasY >= margin + boardH - pointH - checkerR * 2 && canvasY <= margin + boardH) {
return { type: 'point', index: i };
}
}
}
return null;
}
export function getCheckerScreenPos(pointIndex, stackIdx, layout) {
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout;
const barX = margin + 6 * pointW;
const bearX = margin + innerW;
if (pointIndex === 'bar-white') return { x: barX + barWidth / 2, y: margin + boardH * 0.65 + stackIdx * checkerR * 1.9 };
if (pointIndex === 'bar-black') return { x: barX + barWidth / 2, y: margin + boardH * 0.35 - stackIdx * checkerR * 1.9 };
if (pointIndex === 'off') return { x: bearX + bearW / 2, y: margin + boardH / 2 };
const { x, y, isTop } = getPointCoords(pointIndex, margin, pointW, pointH, barWidth, boardH);
const spacing = Math.min(checkerR * 2, pointH / 5);
const cx = x + pointW / 2;
const cy = isTop ? y + checkerR + stackIdx * spacing : y - checkerR - stackIdx * spacing;
return { x: cx, y: cy };
}
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();
}
// Dice renderer — draws animated dice for backgammon
// 3D dice renderer with physics bounce & spin animation
const DIE_SIZE = 36;
const DIE_SIZE = 38;
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;
const { used = [], rolling = false, frame = 0, totalFrames = 24 } = options;
for (let i = 0; i < values.length; i++) {
const dx = x + i * (DIE_SIZE + 10);
const dx = x + i * (DIE_SIZE + 12);
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);
// Stagger animation per die
const dieFrame = rolling ? Math.max(0, frame - i * 3) : 0;
drawSingleDie(ctx, dx, y, val, isUsed, dieFrame, totalFrames);
}
}
function drawSingleDie(ctx, x, y, value, isUsed, frame) {
const rotation = frame > 0 ? (frame / ROLL_FRAMES) * Math.PI * 4 : 0;
function drawSingleDie(ctx, x, y, value, isUsed, frame, totalFrames) {
const t = frame > 0 ? frame / totalFrames : 0;
// Physics: bounce with damping
const bounceY = frame > 0 ? -Math.abs(Math.sin(t * Math.PI * 3)) * 12 * (1 - t) : 0;
const rotation = frame > 0 ? t * Math.PI * 6 * (1 - t * 0.5) : 0;
const scale = frame > 0 ? 1 + Math.sin(t * Math.PI) * 0.15 : 1;
ctx.save();
ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2);
if (frame > 0) ctx.rotate(rotation);
ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2 + bounceY);
ctx.rotate(rotation);
ctx.scale(scale, scale);
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;
// Shadow (moves with bounce)
if (!isUsed) {
const shadowBlur = 6 + Math.abs(bounceY) * 0.5;
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = shadowBlur;
ctx.shadowOffsetY = 3 - bounceY * 0.3;
}
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 5);
// Die body — 3D face with gradient
const bodyGrad = ctx.createLinearGradient(0, 0, DIE_SIZE, DIE_SIZE);
if (isUsed) {
bodyGrad.addColorStop(0, 'rgba(180, 180, 180, 0.35)');
bodyGrad.addColorStop(1, 'rgba(140, 140, 140, 0.25)');
} else {
bodyGrad.addColorStop(0, '#FFFEF5');
bodyGrad.addColorStop(0.4, '#FFF8E8');
bodyGrad.addColorStop(1, '#F0E0C0');
}
ctx.fillStyle = bodyGrad;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 6);
ctx.fill();
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
// Border
ctx.strokeStyle = isUsed ? 'rgba(150,150,150,0.3)' : '#8B6914';
ctx.lineWidth = isUsed ? 1 : 1.5;
roundRect(ctx, 0, 0, DIE_SIZE, DIE_SIZE, 6);
ctx.stroke();
if (isUsed) {
ctx.globalAlpha = 0.4;
// Inner edge highlight (3D effect)
if (!isUsed) {
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
ctx.lineWidth = 1;
roundRect(ctx, 1.5, 1.5, DIE_SIZE - 3, DIE_SIZE - 3, 5);
ctx.stroke();
}
// Dots
ctx.fillStyle = '#1a1a1a';
if (isUsed) ctx.globalAlpha = 0.35;
// Dots with slight shadow
const dotColor = isUsed ? '#666' : '#1A0800';
const positions = getDotPositions(value, DIE_SIZE);
for (const [dx, dy] of positions) {
if (!isUsed) {
ctx.beginPath();
ctx.arc(dx + 0.5, dy + 0.5, DOT_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.15)';
ctx.fill();
}
ctx.beginPath();
ctx.arc(dx, dy, DOT_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = dotColor;
ctx.fill();
}
......@@ -50,10 +91,9 @@ function drawSingleDie(ctx, x, y, value, isUsed, frame) {
}
function getDotPositions(value, size) {
const s = size;
const c = s / 2;
const q1 = s * 0.25;
const q3 = s * 0.75;
const c = size / 2;
const q1 = size * 0.24;
const q3 = size * 0.76;
switch (value) {
case 1: return [[c, c]];
......@@ -77,20 +117,21 @@ function roundRect(ctx, x, y, w, h, r) {
}
export function createRollAnimation(onFrame, onComplete) {
let frame = 0;
const startTime = performance.now();
const totalFrames = 24;
const duration = 700;
let startTime = null;
function tick(now) {
if (!startTime) startTime = now;
const elapsed = now - startTime;
frame = Math.min(ROLL_FRAMES, Math.floor((elapsed / ROLL_DURATION) * ROLL_FRAMES));
onFrame(frame);
const frame = Math.min(totalFrames, Math.floor((elapsed / duration) * totalFrames));
onFrame(frame, totalFrames);
if (elapsed < ROLL_DURATION) {
if (elapsed < duration) {
requestAnimationFrame(tick);
} else {
onComplete();
}
}
requestAnimationFrame(tick);
}
// Move animator — smooth piece transitions
import { getPointCoords } from './board-renderer.js';
// Arc-based move animator with overshoot bounce + particle burst
import { getPointCoords, getCheckerScreenPos } from './board-renderer.js';
const MOVE_DURATION = 300;
const HIT_DURATION = 400;
const MOVE_DURATION = 320;
const HIT_DURATION = 420;
const BEAR_DURATION = 380;
export function createMoveAnimation(from, to, layout, onFrame, onComplete) {
const startPos = getPixelPos(from, layout);
const endPos = getPixelPos(to, layout);
if (!startPos || !endPos) {
onComplete();
return;
}
if (!startPos || !endPos) { onComplete(); return; }
const startTime = performance.now();
const duration = from === 'bar' || to === 'bar' ? HIT_DURATION : MOVE_DURATION;
const isHit = to === 'bar';
const isBear = to === 'off';
const duration = isHit ? HIT_DURATION : isBear ? BEAR_DURATION : MOVE_DURATION;
// Arc height proportional to distance
const dist = Math.hypot(endPos.x - startPos.x, endPos.y - startPos.y);
const arcHeight = Math.min(dist * 0.4, 60);
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 raw = Math.min(1, elapsed / duration);
const x = startPos.x + (endPos.x - startPos.x) * ease;
const y = startPos.y + (endPos.y - startPos.y) * ease;
// Ease: overshoot for moves, bounce for hits
const t = isHit ? easeOutBounce(raw) : easeOutBack(raw);
// Arc for hits (pieces going to bar)
let arcY = 0;
if (to === 'bar') {
arcY = -40 * Math.sin(t * Math.PI);
}
const x = startPos.x + (endPos.x - startPos.x) * t;
const y = startPos.y + (endPos.y - startPos.y) * t;
// Parabolic arc
const arcY = -arcHeight * 4 * raw * (1 - raw);
onFrame({ x, y: y + arcY, progress: t });
// Scale pulse on landing
const scale = raw > 0.85 ? 1 + (1 - raw) * 2 * 0.15 : 1;
if (t < 1) {
onFrame({ x, y: y + arcY, progress: raw, scale, isLanding: raw > 0.95 });
if (raw < 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;
export function createHitAnimation(hitPos, onFrame, onComplete) {
const startTime = performance.now();
const duration = 300;
if (location === 'bar') {
return { x: barX + barWidth / 2, y: margin + boardH / 2 };
function tick(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
const shake = Math.sin(t * Math.PI * 4) * (1 - t) * 6;
const scale = 1 + Math.sin(t * Math.PI) * 0.3;
onFrame({ shake, scale, progress: t });
if (t < 1) requestAnimationFrame(tick);
else onComplete();
}
requestAnimationFrame(tick);
}
export function createBearOffAnimation(pos, onFrame, onComplete) {
const startTime = performance.now();
const duration = 400;
if (location === 'off') {
return { x: bearX + bearOffWidth / 2, y: margin + boardH / 2 };
function tick(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration);
const ease = easeOutCubic(t);
const scale = 1 - ease * 0.6;
const alpha = 1 - ease;
const y = pos.y + ease * 30;
onFrame({ x: pos.x, y, scale, alpha, progress: t });
if (t < 1) requestAnimationFrame(tick);
else onComplete();
}
requestAnimationFrame(tick);
}
function getPixelPos(location, layout) {
if (!layout) return null;
const { margin, pointW, pointH, barWidth, boardH, bearW, innerW, checkerR } = layout;
const barX = margin + 6 * pointW;
const bearX = margin + innerW;
if (location === 'bar') return { x: barX + barWidth / 2, y: margin + boardH / 2 };
if (location === 'off') return { x: bearX + bearW / 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 { x: x + pointW / 2, y: isTop ? y + checkerR * 2 : y - checkerR * 2 };
}
return null;
}
function easeOutBack(t) {
const c1 = 1.70158;
const c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function easeOutBounce(t) {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) { t -= 1.5 / 2.75; return 7.5625 * t * t + 0.75; }
if (t < 2.5 / 2.75) { t -= 2.25 / 2.75; return 7.5625 * t * t + 0.9375; }
t -= 2.625 / 2.75;
return 7.5625 * t * t + 0.984375;
}
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
......@@ -8,11 +8,12 @@ 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 { createCube, canDouble, offerDouble, acceptDouble, declineDouble, shouldBotDouble, shouldBotAccept } 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';
// ═══ State ═══
let canvas, ctx, layout;
let game, match;
let selectedPiece = null;
......@@ -22,78 +23,92 @@ let isRolling = false;
let isAnimating = false;
let myColor = WHITE;
let params = {};
let emotePanel = null;
let turnTimer = null;
let turnTimeLeft = 30;
let rafId = null;
let diceRollFrame = 0;
let diceRollTotal = 24;
let particles = [];
let animatingChecker = null; // {x, y, type, scale}
let lastFrameTime = 0;
let container = null;
export function mountGame(el, p) {
params = p || {};
const { mode = 'bot', variant = 'sheshbesh', 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-card bgg-player-top">
<div class="bgg-avatar-ring bgg-opp-ring"><div class="bgg-avatar">🎯</div></div>
<div class="bgg-player-info">
<div class="bgg-name" id="name-top">${mode === 'bot' ? 'بوت' : 'خصم'}</div>
<div class="bgg-name" id="name-top">${mode === 'bot' ? `بوت (${getDiffLabel(difficulty)})` : params.opponentName || 'خصم'}</div>
<div class="bgg-pips" id="pips-top">167 نقطة</div>
</div>
<div class="bgg-score" id="score-top">0</div>
<div class="bgg-score-badge" 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>
<canvas id="bg-canvas"></canvas>
<div class="bgg-dice-overlay" id="dice-overlay">
<button class="bgg-roll-btn" id="btn-roll">${emoji('game_die', '🎲', 26)} ارمي النرد</button>
</div>
<div class="bgg-cube-area" id="cube-area" style="display:none;">
<button class="bgg-cube-btn" id="btn-double">×2 ضاعف</button>
<button class="bgg-cube-btn" id="btn-double">
<span class="bgg-cube-icon">⬡</span>
<span class="bgg-cube-label">×2 ضاعف</span>
</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 class="bgg-offer-title">مضاعفة!</div>
<p class="bgg-offer-desc">الخصم يريد مضاعفة الرهان إلى <strong id="double-val">2</strong></p>
<div class="bgg-offer-btns">
<button class="bgg-accept-btn" id="btn-accept-double">${emoji('check', '✓', 16)} قبول</button>
<button class="bgg-decline-btn" id="btn-decline-double">${emoji('cross', '✕', 16)} رفض</button>
</div>
</div>
<div class="bgg-no-moves-toast" id="no-moves" style="display:none;">لا حركات متاحة!</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-card bgg-player-bottom">
<div class="bgg-avatar-ring bgg-my-ring"><div class="bgg-avatar">👤</div></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 class="bgg-score-badge bgg-my-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-turn-badge" id="turn-indicator">دورك</div>
<div class="bgg-emote-panel" id="emote-panel" style="display:none;"></div>
<div class="bgg-match-info" id="match-info">
<span class="bgg-match-label">ماتش ${matchLength}</span>
<span class="bgg-variant-label">${getVariantLabel(variant)}</span>
</div>
</div>
${getGameStyles()}
`;
container = el.querySelector('.bgg-container');
canvas = el.querySelector('#bg-canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
// Start first game
resizeCanvas();
startNewGameRound();
// Event listeners
// Event bindings
canvas.addEventListener('click', onCanvasClick);
canvas.addEventListener('touchstart', onCanvasTouch, { passive: false });
el.querySelector('#btn-roll').onclick = onRollClick;
......@@ -104,7 +119,7 @@ export function mountGame(el, p) {
el.querySelector('#btn-quit').onclick = onQuit;
window.addEventListener('resize', resizeCanvas);
// Multiplayer session
// Multiplayer
if (mode === 'live') {
matchSession.create(params.matchId, 'backgammon', {
onOpponentMove: handleServerState,
......@@ -114,14 +129,180 @@ export function mountGame(el, p) {
}
setupEmotePanel(el);
startGameLoop();
}
export function unmountGame() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
window.removeEventListener('resize', resizeCanvas);
matchSession.destroy();
clearInterval(turnTimer);
container = null;
}
// ═══════════════════════════════════════
// 60 FPS GAME LOOP
// ═══════════════════════════════════════
function startGameLoop() {
lastFrameTime = performance.now();
function loop(now) {
const dt = (now - lastFrameTime) / 1000;
lastFrameTime = now;
update(dt);
render();
rafId = requestAnimationFrame(loop);
}
rafId = requestAnimationFrame(loop);
}
function update(dt) {
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vy += (p.gravity || 200) * dt;
p.alpha -= (p.decay || 1.5) * dt;
if (p.rot !== undefined) p.rot += p.vr * dt;
if (p.alpha <= 0) particles.splice(i, 1);
}
}
function render() {
if (!ctx || !game) return;
const w = canvas.width;
const h = canvas.height;
layout = drawBoard(ctx, w, h, game.state, {
highlights,
selectedPoint: selectedPiece?.from ?? null,
particles
});
// Draw dice
if (game.dice && !isRolling) {
const diceCount = game.dice[0] === game.dice[1] ? 4 : 2;
const allDice = game.dice[0] === game.dice[1] ? [game.dice[0], game.dice[0], game.dice[0], game.dice[0]] : game.dice;
const totalDiceW = diceCount * 38 + (diceCount - 1) * 12;
const diceX = w / 2 - totalDiceW / 2;
const diceY = h / 2 - 19;
const usedIndices = getUsedDiceIndices();
drawDice(ctx, diceX, diceY, allDice, { used: usedIndices });
}
// Rolling dice animation
if (isRolling) {
const fakeVals = [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)];
const diceX = w / 2 - 44;
const diceY = h / 2 - 19;
drawDice(ctx, diceX, diceY, fakeVals, { rolling: true, frame: diceRollFrame, totalFrames: diceRollTotal });
}
// Animating checker
if (animatingChecker) {
const { x, y, type, scale = 1 } = animatingChecker;
ctx.save();
ctx.translate(x, y);
ctx.scale(scale, scale);
const r = layout.checkerR;
const isW = type === WHITE;
// Shadow
ctx.beginPath();
ctx.arc(1, 3, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fill();
// Checker
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(-r * 0.3, -r * 0.3, r * 0.1, 0, 0, r);
if (isW) { grad.addColorStop(0, '#FFF'); grad.addColorStop(1, '#C8A882'); }
else { grad.addColorStop(0, '#555'); grad.addColorStop(1, '#0A0A0A'); }
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = isW ? 'rgba(160,120,60,0.5)' : 'rgba(100,100,100,0.5)';
ctx.lineWidth = 1.5;
ctx.stroke();
// Glow
ctx.beginPath();
ctx.arc(0, 0, r + 4, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,215,0,0.6)';
ctx.lineWidth = 2;
ctx.shadowColor = '#FFD700';
ctx.shadowBlur = 12;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
}
// ═══════════════════════════════════════
// PARTICLES
// ═══════════════════════════════════════
function spawnHitParticles(x, y) {
const colors = ['#EF4444', '#F97316', '#FBBF24', '#FFF'];
for (let i = 0; i < 12; i++) {
const angle = (Math.PI * 2 * i) / 12 + Math.random() * 0.3;
const speed = 80 + Math.random() * 120;
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - 60,
alpha: 1,
size: 2 + Math.random() * 3,
color: colors[Math.floor(Math.random() * colors.length)],
gravity: 300,
decay: 2
});
}
}
function spawnBearOffParticles(x, y) {
const colors = ['#10B981', '#34D399', '#6EE7B7', '#FFD700'];
for (let i = 0; i < 8; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.8;
const speed = 60 + Math.random() * 80;
particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
alpha: 1,
size: 2 + Math.random() * 2.5,
color: colors[Math.floor(Math.random() * colors.length)],
shape: Math.random() > 0.5 ? 'star' : 'circle',
rot: 0, vr: (Math.random() - 0.5) * 8,
gravity: 150,
decay: 1.8
});
}
}
function spawnWinParticles() {
const colors = ['#FFD700', '#FFA500', '#FF6347', '#10B981', '#8B5CF6', '#06B6D4'];
for (let i = 0; i < 30; i++) {
const x = Math.random() * canvas.width;
particles.push({
x, y: canvas.height + 10,
vx: (Math.random() - 0.5) * 60,
vy: -(200 + Math.random() * 200),
alpha: 1,
size: 3 + Math.random() * 4,
color: colors[Math.floor(Math.random() * colors.length)],
shape: 'star',
rot: 0, vr: (Math.random() - 0.5) * 10,
gravity: 80,
decay: 0.7
});
}
}
// ═══════════════════════════════════════
// GAME LOGIC
// ═══════════════════════════════════════
function startNewGameRound() {
game = startNewGame(match);
selectedPiece = null;
......@@ -129,73 +310,67 @@ function startNewGameRound() {
highlights = [];
isRolling = false;
isAnimating = false;
render();
animatingChecker = null;
particles = [];
updateUI();
updateTurnIndicator();
if (game.turn !== myColor && params.mode === 'bot') {
setTimeout(doBotTurn, 800);
setTimeout(doBotTurn, 1000);
}
}
// ===== RENDERING =====
function getUsedDiceIndices() {
const usedIndices = [];
const isDoubles = game.dice[0] === game.dice[1];
const allDice = isDoubles ? [game.dice[0], game.dice[0], game.dice[0], game.dice[0]] : [...game.dice];
for (const played of game.movesPlayed) {
for (let j = 0; j < allDice.length; j++) {
if (!usedIndices.includes(j) && allDice[j] === played) {
usedIndices.push(j);
break;
}
}
}
return usedIndices;
}
// ═══════════════════════════════════════
// UI UPDATES
// ═══════════════════════════════════════
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;
const area = canvas.parentElement;
const w = Math.min(area.clientWidth - 8, 420);
const h = Math.min(w * 1.12, 470);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = w * dpr;
canvas.height = h * dpr;
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 });
}
ctx.scale(dpr, dpr);
// Reset scale on next render
canvas._logicalW = w;
canvas._logicalH = h;
}
function updateUI() {
if (!game) return;
if (!game || !container) 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];
setText('#pips-top', getPipCount(game.state, topColor) + ' نقطة');
setText('#pips-bottom', getPipCount(game.state, myColor) + ' نقطة');
setText('#score-top', String(match.scores[topColor]));
setText('#score-bottom', String(match.scores[myColor]));
// Show/hide roll button
const rollBtn = doc.querySelector('#btn-roll');
const rollBtn = container.querySelector('#btn-roll');
const isMyTurn = game.turn === myColor;
rollBtn.style.display = (isMyTurn && !game.dice && !isAnimating) ? '' : 'none';
const diceOverlay = container.querySelector('#dice-overlay');
if (diceOverlay) diceOverlay.style.display = (isMyTurn && !game.dice && !isAnimating && !isRolling) ? '' : 'none';
// Show/hide double button
const cubeArea = doc.querySelector('#cube-area');
const cubeArea = container.querySelector('#cube-area');
if (match.cube && canUseCube(match) && isMyTurn && !game.dice && canDouble(match.cube, myColor)) {
cubeArea.style.display = '';
} else {
......@@ -204,14 +379,21 @@ function updateUI() {
}
function updateTurnIndicator() {
const indicator = canvas?.closest('.bgg-container')?.querySelector('#turn-indicator');
if (!indicator) return;
const el = container?.querySelector('#turn-indicator');
if (!el) return;
const isMyTurn = game.turn === myColor;
indicator.textContent = isMyTurn ? 'دورك' : 'دور الخصم';
indicator.className = `bgg-turn-indicator ${isMyTurn ? 'bgg-my-turn' : 'bgg-opp-turn'}`;
el.textContent = isMyTurn ? 'دورك' : 'دور الخصم';
el.className = `bgg-turn-badge ${isMyTurn ? 'bgg-turn-mine' : 'bgg-turn-opp'}`;
}
function setText(sel, text) {
const el = container?.querySelector(sel);
if (el) el.textContent = text;
}
// ===== INPUT HANDLING =====
// ═══════════════════════════════════════
// INPUT
// ═══════════════════════════════════════
function onCanvasClick(e) {
if (isAnimating || isRolling) return;
......@@ -219,8 +401,10 @@ function onCanvasClick(e) {
if (!game.dice || game.movesLeft.length === 0) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const scaleX = (canvas._logicalW || canvas.width) / rect.width;
const scaleY = (canvas._logicalH || canvas.height) / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
handleBoardTap(x, y);
}
......@@ -232,19 +416,17 @@ function onCanvasTouch(e) {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
const scaleX = (canvas._logicalW || canvas.width) / rect.width;
const scaleY = (canvas._logicalH || canvas.height) / rect.height;
const x = (touch.clientX - rect.left) * scaleX;
const y = (touch.clientY - rect.top) * scaleY;
handleBoardTap(x, y);
}
function handleBoardTap(x, y) {
const hit = hitTest(x, y, layout, game.state);
if (!hit) {
clearSelection();
return;
}
if (!hit) { clearSelection(); return; }
// If tapping a highlighted destination
if (selectedPiece && hit.type === 'point' && highlights.includes(hit.index)) {
executeMove(hit.index);
return;
......@@ -254,14 +436,9 @@ function handleBoardTap(x, y) {
return;
}
// Select a piece
if (hit.type === 'point') {
selectPoint(hit.index);
} else if (hit.type === 'bar' && hit.player === game.turn) {
selectBar();
} else {
clearSelection();
}
if (hit.type === 'point') selectPoint(hit.index);
else if (hit.type === 'bar' && hit.player === game.turn) selectBar();
else clearSelection();
}
function selectPoint(pointIndex) {
......@@ -270,118 +447,112 @@ function selectPoint(pointIndex) {
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;
}
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 fromPos = selectedPiece.from;
const toPos = destination;
const pieceType = game.turn;
const actions = applyMove(game, move.piece.id, move.steps);
if (!actions) { isAnimating = false; return; }
audio.play('sfx_bg_piece_move', 'game');
// 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');
}
// Start arc animation
createMoveAnimation(fromPos, toPos, layout,
(frame) => {
animatingChecker = { x: frame.x, y: frame.y, type: pieceType, scale: frame.scale };
},
() => {
animatingChecker = null;
const actions = applyMove(game, move.piece.id, move.steps);
if (!actions) { isAnimating = false; return; }
if (actions.some(a => a.type === 'hit')) {
audio.play('sfx_bg_piece_hit', 'game');
const hitPos = layout ? { x: canvas._logicalW / 2, y: canvas._logicalH / 2 } : { x: 180, y: 200 };
spawnHitParticles(hitPos.x, hitPos.y);
}
if (actions.some(a => a.type === 'bear')) {
audio.play('sfx_bg_bear_off', 'game');
spawnBearOffParticles(canvas._logicalW - 20, canvas._logicalH / 2);
}
clearSelection();
isAnimating = false;
render();
updateUI();
clearSelection();
isAnimating = false;
updateUI();
// Check if game over
if (game.isOver) {
handleGameOver();
return;
}
if (game.isOver) { handleGameOver(); return; }
if (game.movesLeft.length === 0) { endTurn(); 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();
}
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);
setTimeout(doBotTurn, 700);
} else if (params.mode === 'live') {
syncToServer();
}
}
// ===== DICE ROLLING =====
// ═══════════════════════════════════════
// DICE
// ═══════════════════════════════════════
function onRollClick() {
if (game.dice || isRolling || isAnimating) return;
if (game.turn !== myColor && params.mode !== 'local-multi') return;
isRolling = true;
diceRollFrame = 0;
audio.play('sfx_bg_dice_roll', 'game');
createRollAnimation(
() => render(),
(frame, total) => { diceRollFrame = frame; diceRollTotal = total; },
() => {
isRolling = false;
const result = rollDice(game);
diceRollFrame = 0;
rollDice(game);
// If no playable moves, skip turn
if (game.movesLeft.length === 0) {
render();
bus.emit('toast', { text: 'لا حركات متاحة!' });
setTimeout(endTurn, 1000);
showNoMoves();
setTimeout(endTurn, 1200);
return;
}
render();
updateUI();
// Auto-play if only one possible move sequence
// Auto-play single forced move
const moves = getValidMoves(game);
if (moves.length === 1 && game.movesLeft.length === 1) {
setTimeout(() => {
......@@ -393,11 +564,22 @@ function onRollClick() {
);
}
// ===== DOUBLING CUBE =====
function showNoMoves() {
const el = container?.querySelector('#no-moves');
if (!el) return;
el.style.display = '';
el.classList.add('bgg-toast-show');
setTimeout(() => { el.style.display = 'none'; el.classList.remove('bgg-toast-show'); }, 1200);
}
// ═══════════════════════════════════════
// DOUBLING CUBE
// ═══════════════════════════════════════
function onDoubleClick() {
if (!match.cube || !canDouble(match.cube, myColor)) return;
if (game.dice) return;
audio.play('sfx_bg_double', 'game');
if (params.mode === 'bot') {
offerDouble(match.cube, myColor);
......@@ -406,9 +588,9 @@ function onDoubleClick() {
if (shouldBotAccept(match.cube, oppPips, myPips, params.difficulty)) {
acceptDouble(match.cube);
bus.emit('toast', { text: 'البوت قبل المضاعفة!' });
bus.emit('toast', { text: `البوت قبل المضاعفة! ×${match.cube.value}` });
} else {
const loser = declineDouble(match.cube);
declineDouble(match.cube);
bus.emit('toast', { text: 'البوت رفض! فزت بالجولة' });
const result = endGame(match, myColor, 1);
showGameResult(result);
......@@ -424,8 +606,8 @@ function onDoubleClick() {
function onAcceptDouble() {
if (!match.cube) return;
acceptDouble(match.cube);
const offerUI = canvas?.closest('.bgg-container')?.querySelector('#double-offer');
if (offerUI) offerUI.style.display = 'none';
audio.play('sfx_bg_double', 'game');
container.querySelector('#double-offer').style.display = 'none';
updateUI();
if (params.mode === 'live') syncToServer();
}
......@@ -433,13 +615,14 @@ function onAcceptDouble() {
function onDeclineDouble() {
if (!match.cube) return;
declineDouble(match.cube);
const offerUI = canvas?.closest('.bgg-container')?.querySelector('#double-offer');
if (offerUI) offerUI.style.display = 'none';
container.querySelector('#double-offer').style.display = 'none';
const result = endGame(match, game.turn, 1);
showGameResult(result);
}
// ===== BOT =====
// ═══════════════════════════════════════
// BOT AI
// ═══════════════════════════════════════
function doBotTurn() {
if (game.turn === myColor || game.isOver) return;
......@@ -450,35 +633,36 @@ function doBotTurn() {
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');
audio.play('sfx_bg_double', 'game');
const offerUI = container.querySelector('#double-offer');
const valEl = 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
// Roll
audio.play('sfx_bg_dice_roll', 'game');
isRolling = true;
diceRollFrame = 0;
setTimeout(() => {
isRolling = false;
rollDice(game);
render();
createRollAnimation(
(frame, total) => { diceRollFrame = frame; diceRollTotal = total; },
() => {
isRolling = false;
rollDice(game);
if (game.movesLeft.length === 0) {
setTimeout(endTurn, 600);
return;
if (game.movesLeft.length === 0) {
setTimeout(endTurn, 700);
return;
}
makeBotMoves();
}
// Make moves one by one
makeBotMoves(0);
}, 500);
);
}
function makeBotMoves(moveIndex) {
function makeBotMoves() {
if (game.movesLeft.length === 0 || game.isOver) {
if (!game.isOver) endTurn();
else handleGameOver();
......@@ -486,33 +670,50 @@ function makeBotMoves(moveIndex) {
}
const move = getBotMove(game, params.difficulty);
if (!move) {
endTurn();
return;
}
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();
const fromPos = move.from;
const toPos = move.to;
const pieceType = game.turn;
if (game.isOver) {
handleGameOver();
} else {
makeBotMoves(moveIndex + 1);
isAnimating = true;
createMoveAnimation(fromPos, toPos, layout,
(frame) => { animatingChecker = { x: frame.x, y: frame.y, type: pieceType, scale: frame.scale }; },
() => {
animatingChecker = null;
audio.play('sfx_bg_piece_move', 'game');
const actions = applyMove(game, move.piece.id, move.steps);
if (actions?.some(a => a.type === 'hit')) {
audio.play('sfx_bg_piece_hit', 'game');
spawnHitParticles(canvas._logicalW / 2, canvas._logicalH / 2);
}
if (actions?.some(a => a.type === 'bear')) {
audio.play('sfx_bg_bear_off', 'game');
spawnBearOffParticles(canvas._logicalW - 20, canvas._logicalH / 2);
}
isAnimating = false;
updateUI();
if (game.isOver) { handleGameOver(); return; }
setTimeout(makeBotMoves, 350);
}
}, 400);
);
}
// ===== GAME OVER =====
// ═══════════════════════════════════════
// GAME OVER
// ═══════════════════════════════════════
function handleGameOver() {
const result = endGame(match, game.winner, game.score);
audio.play(game.winner === myColor ? 'sfx_bg_win_game' : 'lose', 'game');
const winner = game.winner;
const isMyWin = winner === myColor;
const result = endGame(match, winner, game.score || 1);
audio.play(isMyWin ? 'sfx_bg_win_game' : 'lose', 'game');
if (isMyWin) spawnWinParticles();
showGameResult(result);
}
......@@ -520,6 +721,8 @@ function showGameResult(result) {
updateUI();
if (result.matchOver) {
audio.play(result.winner === myColor ? 'sfx_bg_win_match' : 'lose', 'game');
if (result.winner === myColor) spawnWinParticles();
setTimeout(() => {
if (params.mode === 'live') syncComplete(result);
scene.replace('backgammon-result', {
......@@ -528,32 +731,30 @@ function showGameResult(result) {
matchLength: match.length,
mode: params.mode
});
}, 1500);
}, 2000);
} 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);
setTimeout(startNewGameRound, 2500);
}
}
function endMatchWithWin() {
scene.replace('backgammon-result', {
winner: 'you',
scores: match.scores,
matchLength: match.length,
mode: params.mode,
reason: 'abandon'
winner: 'you', scores: match.scores, matchLength: match.length,
mode: params.mode, reason: 'abandon'
});
}
// ===== MULTIPLAYER SYNC =====
// ═══════════════════════════════════════
// MULTIPLAYER SYNC
// ═══════════════════════════════════════
function syncToServer() {
if (params.mode !== 'live') return;
net.post('backgammon-match.php', {
action: 'move',
match_id: params.matchId,
action: 'move', match_id: params.matchId,
game_state: serializeState(game),
match_state: getMatchState(match),
cube: match.cube
......@@ -562,8 +763,7 @@ function syncToServer() {
function syncComplete(result) {
net.post('backgammon-match.php', {
action: 'complete',
match_id: params.matchId,
action: 'complete', match_id: params.matchId,
winner: result.winner === myColor ? store.get('auth.userId') : 'opponent',
reason: 'normal'
}).catch(() => {});
......@@ -572,20 +772,18 @@ function syncComplete(result) {
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.match_state) match.scores = data.match_state.scores || match.scores;
if (data.cube) match.cube = data.cube;
render();
updateUI();
updateTurnIndicator();
}
}
// ===== EMOTE PANEL =====
// ═══════════════════════════════════════
// EMOTES
// ═══════════════════════════════════════
function setupEmotePanel(el) {
const panel = el.querySelector('#emote-panel');
......@@ -597,49 +795,42 @@ function setupEmotePanel(el) {
<div class="bgg-phrases">${phrases.map(p => `<button class="bgg-phrase-btn">${p}</button>`).join('')}</div>
`;
let lastEmote = 0;
panel.querySelectorAll('.bgg-emote-btn').forEach(btn => {
btn.onclick = () => {
if (Date.now() - lastEmote < 3000) return;
lastEmote = Date.now();
audio.play('sfx_emote');
showEmoteBubble(btn.textContent, 'bottom');
showBubble(btn.textContent, 'emote');
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 = () => {
if (Date.now() - lastEmote < 3000) return;
lastEmote = Date.now();
audio.play('sfx_emote');
showPhraseBubble(btn.textContent, 'bottom');
showBubble(btn.textContent, 'phrase');
panel.style.display = 'none';
};
});
}
function toggleEmotePanel() {
const panel = canvas?.closest('.bgg-container')?.querySelector('#emote-panel');
const panel = 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');
function showBubble(text, type) {
if (!container) return;
const bubble = document.createElement('div');
bubble.className = `bgg-phrase-bubble bgg-phrase-${position}`;
bubble.className = `bgg-bubble bgg-bubble-${type}`;
bubble.textContent = text;
container.appendChild(bubble);
setTimeout(() => bubble.remove(), 3000);
setTimeout(() => bubble.remove(), 2500);
}
function onQuit() {
......@@ -652,119 +843,189 @@ function onQuit() {
scene.replace('backgammon-room', { mode: 'menu' });
}
// ═══════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════
function getDiffLabel(d) {
if (d === 'easy') return 'سهل';
if (d === 'hard') return 'صعب';
return 'متوسط';
}
function getVariantLabel(v) {
if (v === 'mahbousa') return 'محبوسة';
if (v === 'thirtyone') return '٣١';
return 'شيش بيش';
}
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-container {
position:relative;display:flex;flex-direction:column;
height:100%;background:linear-gradient(180deg,#060A10 0%,#0A1020 100%);
color:#f8fafc;overflow:hidden;font-family:var(--font-ar,'IBM Plex Sans Arabic',sans-serif);
}
.bgg-top-bar,.bgg-bottom-bar {
display:flex;align-items:center;padding:10px 14px;gap:10px;
background:rgba(0,0,0,0.4);backdrop-filter:blur(8px);
border-bottom:1px solid rgba(255,255,255,0.04);
}
.bgg-bottom-bar { border-bottom:none;border-top:1px solid rgba(255,255,255,0.04);justify-content:space-between; }
.bgg-player-card { display:flex;align-items:center;gap:10px;flex:1; }
.bgg-avatar-ring {
width:40px;height:40px;border-radius:50%;padding:2px;
background:conic-gradient(from 0deg,rgba(148,163,184,0.3),rgba(148,163,184,0.1));
}
.bgg-my-ring { background:conic-gradient(from 0deg,rgba(16,185,129,0.5),rgba(16,185,129,0.2)); }
.bgg-opp-ring { background:conic-gradient(from 0deg,rgba(239,68,68,0.4),rgba(239,68,68,0.15)); }
.bgg-avatar {
width:100%;height:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;
background:rgba(255,255,255,0.04);font-size:18px;
}
.bgg-player-info { flex:1; }
.bgg-name { font-size:13px;font-weight:700;color:#f8fafc; }
.bgg-pips { font-size:11px;color:#64748b; }
.bgg-score-badge {
font-size:18px;font-weight:800;color:#94a3b8;
min-width:28px;height:28px;border-radius:8px;
display:flex;align-items:center;justify-content:center;
background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06);
}
.bgg-my-score { color:#d4940a;border-color:rgba(212,148,10,0.2);background:rgba(212,148,10,0.06); }
.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-board-area {
flex:1;display:flex;align-items:center;justify-content:center;
position:relative;padding:4px;
}
#bg-canvas { border-radius:8px;touch-action:none;cursor:pointer; }
.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; }
.bgg-dice-overlay {
position:absolute;bottom:16px;left:50%;transform:translateX(-50%);z-index:5;
}
.bgg-roll-btn {
display:flex;align-items:center;gap:8px;
padding:14px 28px;border-radius:16px;border:none;
background:linear-gradient(135deg,#d4940a 0%,#8B4513 100%);
color:#fff;font-size:16px;font-weight:800;cursor:pointer;
box-shadow:0 6px 24px rgba(212,148,10,0.5),inset 0 1px 0 rgba(255,255,255,0.2);
animation:rollPulse 2.5s ease-in-out infinite;
transition:transform 0.15s var(--ease-spring);
}
.bgg-roll-btn:active { transform:scale(0.92);animation:none; }
@keyframes rollPulse {
0%,100%{box-shadow:0 6px 24px rgba(212,148,10,0.4)}
50%{box-shadow:0 6px 32px rgba(212,148,10,0.7),0 0 60px rgba(212,148,10,0.15)}
}
.bgg-cube-area { position:absolute;top:12px;right:12px;z-index:5; }
.bgg-cube-btn {
display:flex;align-items:center;gap:6px;
padding:8px 14px;border-radius:10px;border:none;
background:rgba(139,92,246,0.15);border:1.5px solid rgba(139,92,246,0.4);
color:#a78bfa;font-size:12px;font-weight:700;cursor:pointer;
transition:all 0.2s;
}
.bgg-cube-btn:active { transform:scale(0.9);background:rgba(139,92,246,0.3); }
.bgg-cube-icon { font-size:16px; }
.bgg-double-offer {
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
background:rgba(10,16,32,0.95);border:1.5px solid rgba(212,148,10,0.5);
border-radius:20px;padding:24px 28px;text-align:center;z-index:20;
backdrop-filter:blur(12px);box-shadow:0 12px 40px rgba(0,0,0,0.6);
animation:offerIn 0.3s var(--ease-spring);
}
@keyframes offerIn { from{opacity:0;transform:translate(-50%,-50%) scale(0.85)} to{opacity:1;transform:translate(-50%,-50%) scale(1)} }
.bgg-offer-title { font-size:18px;font-weight:800;color:#d4940a;margin-bottom:8px; }
.bgg-offer-desc { font-size:13px;color:#94a3b8;margin:0 0 16px; }
.bgg-offer-btns { display:flex;gap:10px;justify-content:center; }
.bgg-accept-btn,.bgg-decline-btn {
padding:10px 22px;border-radius:12px;border:none;font-weight:700;font-size:13px;cursor:pointer;
transition:transform 0.15s var(--ease-spring);
}
.bgg-accept-btn { background:linear-gradient(135deg,#10B981,#059669);color:#fff;box-shadow:0 4px 12px rgba(16,185,129,0.3); }
.bgg-decline-btn { background:linear-gradient(135deg,#EF4444,#DC2626);color:#fff;box-shadow:0 4px 12px rgba(239,68,68,0.3); }
.bgg-accept-btn:active,.bgg-decline-btn:active { transform:scale(0.9); }
.bgg-no-moves-toast {
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
padding:12px 24px;border-radius:12px;
background:rgba(239,68,68,0.15);border:1px solid rgba(239,68,68,0.3);
color:#f87171;font-size:14px;font-weight:700;
animation:toastIn 0.3s var(--ease-spring);
}
@keyframes toastIn { from{opacity:0;transform:translate(-50%,-50%) scale(0.8)} }
.bgg-actions { display:flex;gap:8px; }
.bgg-action-btn {
width:38px;height:38px;border-radius:12px;border:none;
background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.06);
color:#94a3b8;font-size:16px;cursor:pointer;
display:flex;align-items:center;justify-content:center;
transition:all 0.15s;
}
.bgg-action-btn:active { transform:scale(0.85);background:rgba(255,255,255,0.08); }
.bgg-action-quit { color:#ef4444;border-color:rgba(239,68,68,0.2); }
.bgg-turn-badge {
position:absolute;top:50%;left:6px;transform:translateY(-50%);
writing-mode:vertical-rl;font-size:10px;font-weight:800;letter-spacing:0.5px;
padding:8px 5px;border-radius:8px;
transition:all 0.3s var(--ease-spring);
}
.bgg-turn-mine { background:rgba(16,185,129,0.12);color:#34D399;border:1px solid rgba(16,185,129,0.2); }
.bgg-turn-opp { background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.15); }
.bgg-match-info {
position:absolute;top:50%;right:6px;transform:translateY(-50%);
display:flex;flex-direction:column;align-items:center;gap:4px;
}
.bgg-match-label,.bgg-variant-label {
writing-mode:vertical-rl;font-size:9px;font-weight:600;color:#475569;
padding:4px 3px;border-radius:4px;background:rgba(255,255,255,0.02);
}
@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>`;
.bgg-emote-panel {
position:absolute;bottom:64px;right:14px;
background:rgba(10,16,32,0.95);border:1px solid rgba(255,255,255,0.08);
border-radius:16px;padding:14px;z-index:25;max-width:260px;
backdrop-filter:blur(12px);box-shadow:0 8px 32px rgba(0,0,0,0.5);
animation:panelIn 0.2s var(--ease-spring);
}
@keyframes panelIn { from{opacity:0;transform:translateY(8px) scale(0.95)} }
.bgg-emotes { display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px; }
.bgg-emote-btn {
width:38px;height:38px;border-radius:10px;border:none;
background:rgba(255,255,255,0.05);font-size:20px;cursor:pointer;
transition:transform 0.15s var(--ease-spring);
}
.bgg-emote-btn:active { transform:scale(0.85);background:rgba(255,255,255,0.1); }
.bgg-phrases { display:flex;flex-direction:column;gap:4px; }
.bgg-phrase-btn {
padding:7px 12px;border-radius:8px;border:none;
background:rgba(255,255,255,0.03);color:#94a3b8;
font-size:12px;cursor:pointer;text-align:right;
transition:background 0.15s;
}
.bgg-phrase-btn:active { background:rgba(255,255,255,0.08); }
.bgg-bubble {
position:absolute;pointer-events:none;z-index:30;
animation:bubbleFloat 2.5s ease-out forwards;
}
.bgg-bubble-emote { bottom:80px;right:60px;font-size:44px; }
.bgg-bubble-phrase {
bottom:80px;right:20px;
padding:10px 16px;border-radius:14px;
background:rgba(212,148,10,0.12);border:1px solid rgba(212,148,10,0.25);
color:#d4940a;font-size:13px;font-weight:700;
}
@keyframes bubbleFloat {
0%{opacity:1;transform:translateY(0) scale(1)}
70%{opacity:1}
100%{opacity:0;transform:translateY(-50px) scale(1.15)}
}
</style>`;
}
......@@ -3,6 +3,8 @@ import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import { emoji } from '../../../core/theme.js';
let confettiRaf = null;
export function mountResult(el, params) {
const { winner = 'you', scores = [0, 0], matchLength = 3, mode = 'bot', reason = '' } = params || {};
const didWin = winner === 'you';
......@@ -11,31 +13,40 @@ export function mountResult(el, params) {
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>
<canvas class="bgr-confetti" id="confetti-canvas"></canvas>
<div class="bgr-content">
<div class="bgr-hero">
<div class="bgr-trophy-ring ${didWin ? 'bgr-win-ring' : 'bgr-lose-ring'}">
<div class="bgr-trophy">${didWin ? emoji('trophy', '🏆', 56) : emoji('pensive', '😔', 56)}</div>
</div>
<h1 class="bgr-title">${didWin ? 'مبروك! فزت بالماتش!' : 'خسرت هالمرة...'}</h1>
<p class="bgr-subtitle">${reason === 'abandon' ? 'الخصم انسحب من اللعبة' : didWin ? 'أداء ممتاز!' : 'حاول مرة ثانية!'}</p>
</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 class="bgr-scoreboard">
<div class="bgr-score-col">
<div class="bgr-score-val ${didWin ? 'bgr-val-win' : ''}">${scores[0]}</div>
<div class="bgr-score-label">أنت</div>
</div>
<div class="bgr-score-vs">
<div class="bgr-vs-line"></div>
<span class="bgr-vs-text">من ${matchLength}</span>
<div class="bgr-vs-line"></div>
</div>
<div class="bgr-score-col">
<div class="bgr-score-val ${!didWin ? 'bgr-val-win' : ''}">${scores[1]}</div>
<div class="bgr-score-label">الخصم</div>
</div>
</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 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>
</div>
${getResultStyles()}
......@@ -43,49 +54,135 @@ export function mountResult(el, params) {
el.querySelector('#btn-rematch').onclick = () => {
audio.play('click');
stopConfetti();
scene.replace('backgammon-room', { mode: 'menu' });
};
el.querySelector('#btn-exit').onclick = () => {
audio.play('click');
stopConfetti();
scene.exitGameMode();
bus.emit('navigate', { world: 'play', scene: 'play-table' });
};
if (didWin) startConfetti(el.querySelector('#confetti-canvas'));
}
function startConfetti(canvas) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.parentElement.clientWidth;
const h = canvas.parentElement.clientHeight;
canvas.width = w;
canvas.height = h;
const colors = ['#FFD700', '#FF6347', '#10B981', '#8B5CF6', '#06B6D4', '#F59E0B', '#EC4899'];
const confetti = [];
for (let i = 0; i < 80; i++) {
confetti.push({
x: Math.random() * w,
y: -20 - Math.random() * h * 0.5,
vx: (Math.random() - 0.5) * 3,
vy: 1.5 + Math.random() * 3,
size: 4 + Math.random() * 6,
color: colors[Math.floor(Math.random() * colors.length)],
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.15,
shape: Math.random() > 0.5 ? 'rect' : 'circle'
});
}
function draw() {
ctx.clearRect(0, 0, w, h);
for (const c of confetti) {
c.x += c.vx;
c.y += c.vy;
c.rot += c.vr;
c.vx += (Math.random() - 0.5) * 0.1;
if (c.y > h + 20) { c.y = -20; c.x = Math.random() * w; }
ctx.save();
ctx.translate(c.x, c.y);
ctx.rotate(c.rot);
ctx.fillStyle = c.color;
if (c.shape === 'rect') {
ctx.fillRect(-c.size / 2, -c.size / 4, c.size, c.size / 2);
} else {
ctx.beginPath();
ctx.arc(0, 0, c.size / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
confettiRaf = requestAnimationFrame(draw);
}
draw();
}
function stopConfetti() {
if (confettiRaf) { cancelAnimationFrame(confettiRaf); confettiRaf = null; }
}
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; }
.bgr-wrap {
position:relative;display:flex;align-items:center;justify-content:center;
height:100%;
background:linear-gradient(180deg,#060A10 0%,#0A1020 50%,#101828 100%);
overflow:hidden;
}
.bgr-confetti {
position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;
}
.bgr-content {
position:relative;z-index:1;
display:flex;flex-direction:column;align-items:center;padding:24px;width:100%;
}
.bgr-hero { text-align:center;margin-bottom:28px; }
.bgr-trophy-ring {
width:90px;height:90px;border-radius:50%;margin:0 auto 16px;
display:flex;align-items:center;justify-content:center;
animation:trophyFloat 3s ease-in-out infinite;
}
.bgr-win-ring {
background:conic-gradient(from 0deg,rgba(212,148,10,0.3),rgba(255,215,0,0.1),rgba(212,148,10,0.3));
box-shadow:0 0 40px rgba(212,148,10,0.2);
}
.bgr-lose-ring {
background:conic-gradient(from 0deg,rgba(100,100,100,0.2),rgba(60,60,60,0.1),rgba(100,100,100,0.2));
}
.bgr-trophy { font-size:56px; }
.bgr-title { font-size:22px;font-weight:800;color:#f8fafc;margin:0; }
.bgr-subtitle { font-size:13px;color:#64748b;margin:8px 0 0; }
@keyframes trophyFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-8px)} }
.bgr-scoreboard {
display:flex;align-items:center;gap:20px;
padding:24px 36px;border-radius:20px;
background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);
backdrop-filter:blur(8px);margin-bottom:32px;
}
.bgr-score-col { display:flex;flex-direction:column;align-items:center;gap:6px; }
.bgr-score-val { font-size:36px;font-weight:900;color:#64748b;line-height:1; }
.bgr-val-win { color:#d4940a;text-shadow:0 0 20px rgba(212,148,10,0.4); }
.bgr-score-label { font-size:12px;color:#475569;font-weight:600; }
.bgr-score-vs { display:flex;align-items:center;gap:8px; }
.bgr-vs-line { width:20px;height:1px;background:rgba(255,255,255,0.08); }
.bgr-vs-text { font-size:11px;color:#475569;white-space:nowrap; }
@keyframes bgFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
</style>`;
.bgr-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:280px; }
.bgr-btn {
padding:15px 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.93); }
.bgr-btn-primary {
background:linear-gradient(135deg,#d4940a,#8B4513);color:#fff;
box-shadow:0 6px 20px rgba(212,148,10,0.35);
}
.bgr-btn-secondary {
background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);color:#94a3b8;
}
</style>`;
}
// Doubling cube UI overlay
// Doubling cube 3D display widget
export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) {
let existing = container.querySelector('.bgg-cube-display');
if (!existing) {
......@@ -17,7 +17,26 @@ export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) {
existing.innerHTML = `
<div class="bgg-cube bgg-cube-${position}">
<span class="bgg-cube-val">${cubeValue}</span>
<div class="bgg-cube-face">
<span class="bgg-cube-val">${cubeValue}</span>
</div>
</div>
`;
}
export function createCubeStyles() {
return `
.bgg-cube-display { position:absolute;z-index:4;pointer-events:none; }
.bgg-cube-center { top:50%;right:8px;transform:translateY(-50%); }
.bgg-cube-top { top:8px;right:8px; }
.bgg-cube-bottom { bottom:8px;right:8px; }
.bgg-cube-face {
width:32px;height:32px;border-radius:6px;
background:linear-gradient(135deg,rgba(139,92,246,0.2),rgba(139,92,246,0.05));
border:1.5px solid rgba(139,92,246,0.4);
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 8px rgba(139,92,246,0.2);
}
.bgg-cube-val { font-size:14px;font-weight:900;color:#a78bfa; }
`;
}
// Emote panel for backgammon — reuses Ludo pattern
// Emote panel for backgammon — with cooldown + net sync
import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
......
// Move hints — shows valid destination indicators on the board
// Move hints — computes valid destination list for selected piece
export function showHints(validMoves, selectedPieceId) {
if (!selectedPieceId) return [];
return validMoves
.filter(m => m.piece.id === selectedPieceId)
.map(m => m.to);
.map(m => m.to === 'off' ? 'off' : m.to);
}
export function clearHints() {
return [];
}
export function getMovablePieces(validMoves) {
const pieces = new Set();
for (const m of validMoves) {
pieces.add(m.piece.id);
}
return [...pieces];
}
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