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'])) { ...@@ -365,6 +365,68 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
</div> </div>
</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 --> <!-- ANIMATIONS -->
<h2>⚡ الحركة والرسوم المتحركة</h2> <h2>⚡ الحركة والرسوم المتحركة</h2>
<div class="section"> <div class="section">
......
...@@ -25,6 +25,21 @@ ...@@ -25,6 +25,21 @@
--backgammon-primary: #F59E0B; --backgammon-primary: #F59E0B;
--backgammon-secondary: #EF4444; --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-primary: #F8FAFC;
--text-secondary: #94A3B8; --text-secondary: #94A3B8;
--text-muted: #475569; --text-muted: #475569;
......
...@@ -73,6 +73,20 @@ function applyColors() { ...@@ -73,6 +73,20 @@ function applyColors() {
if (themeData[key]) root.setProperty(cssVar, themeData[key]); 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 // Also expose ALL theme values as a flat map for JS access
window.__EL3AB_THEME = themeData; window.__EL3AB_THEME = themeData;
} }
......
// 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 DOT_RADIUS = 3.5;
const ROLL_FRAMES = 18;
const ROLL_DURATION = 600;
export function drawDice(ctx, x, y, values, options = {}) { 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++) { 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 val = rolling ? Math.floor(Math.random() * 6) + 1 : values[i];
const isUsed = used.includes(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) { function drawSingleDie(ctx, x, y, value, isUsed, frame, totalFrames) {
const rotation = frame > 0 ? (frame / ROLL_FRAMES) * Math.PI * 4 : 0; 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.save();
ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2); ctx.translate(x + DIE_SIZE / 2, y + DIE_SIZE / 2 + bounceY);
if (frame > 0) ctx.rotate(rotation); ctx.rotate(rotation);
ctx.scale(scale, scale);
ctx.translate(-DIE_SIZE / 2, -DIE_SIZE / 2); ctx.translate(-DIE_SIZE / 2, -DIE_SIZE / 2);
// Die body // Shadow (moves with bounce)
ctx.fillStyle = isUsed ? 'rgba(200, 200, 200, 0.4)' : '#fffef0'; if (!isUsed) {
ctx.strokeStyle = isUsed ? 'rgba(150, 150, 150, 0.5)' : '#8B4513'; const shadowBlur = 6 + Math.abs(bounceY) * 0.5;
ctx.lineWidth = 1.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.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(); ctx.stroke();
if (isUsed) { // Inner edge highlight (3D effect)
ctx.globalAlpha = 0.4; 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 if (isUsed) ctx.globalAlpha = 0.35;
ctx.fillStyle = '#1a1a1a';
// Dots with slight shadow
const dotColor = isUsed ? '#666' : '#1A0800';
const positions = getDotPositions(value, DIE_SIZE); const positions = getDotPositions(value, DIE_SIZE);
for (const [dx, dy] of positions) { 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.beginPath();
ctx.arc(dx, dy, DOT_RADIUS, 0, Math.PI * 2); ctx.arc(dx, dy, DOT_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = dotColor;
ctx.fill(); ctx.fill();
} }
...@@ -50,10 +91,9 @@ function drawSingleDie(ctx, x, y, value, isUsed, frame) { ...@@ -50,10 +91,9 @@ function drawSingleDie(ctx, x, y, value, isUsed, frame) {
} }
function getDotPositions(value, size) { function getDotPositions(value, size) {
const s = size; const c = size / 2;
const c = s / 2; const q1 = size * 0.24;
const q1 = s * 0.25; const q3 = size * 0.76;
const q3 = s * 0.75;
switch (value) { switch (value) {
case 1: return [[c, c]]; case 1: return [[c, c]];
...@@ -77,20 +117,21 @@ function roundRect(ctx, x, y, w, h, r) { ...@@ -77,20 +117,21 @@ function roundRect(ctx, x, y, w, h, r) {
} }
export function createRollAnimation(onFrame, onComplete) { export function createRollAnimation(onFrame, onComplete) {
let frame = 0; const totalFrames = 24;
const startTime = performance.now(); const duration = 700;
let startTime = null;
function tick(now) { function tick(now) {
if (!startTime) startTime = now;
const elapsed = now - startTime; const elapsed = now - startTime;
frame = Math.min(ROLL_FRAMES, Math.floor((elapsed / ROLL_DURATION) * ROLL_FRAMES)); const frame = Math.min(totalFrames, Math.floor((elapsed / duration) * totalFrames));
onFrame(frame); onFrame(frame, totalFrames);
if (elapsed < ROLL_DURATION) { if (elapsed < duration) {
requestAnimationFrame(tick); requestAnimationFrame(tick);
} else { } else {
onComplete(); onComplete();
} }
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
// Move animator — smooth piece transitions // Arc-based move animator with overshoot bounce + particle burst
import { getPointCoords } from './board-renderer.js'; import { getPointCoords, getCheckerScreenPos } from './board-renderer.js';
const MOVE_DURATION = 300; const MOVE_DURATION = 320;
const HIT_DURATION = 400; const HIT_DURATION = 420;
const BEAR_DURATION = 380;
export function createMoveAnimation(from, to, layout, onFrame, onComplete) { export function createMoveAnimation(from, to, layout, onFrame, onComplete) {
const startPos = getPixelPos(from, layout); const startPos = getPixelPos(from, layout);
const endPos = getPixelPos(to, layout); const endPos = getPixelPos(to, layout);
if (!startPos || !endPos) { if (!startPos || !endPos) { onComplete(); return; }
onComplete();
return;
}
const startTime = performance.now(); 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) { function tick(now) {
const elapsed = now - startTime; const elapsed = now - startTime;
const t = Math.min(1, elapsed / duration); const raw = 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; // Ease: overshoot for moves, bounce for hits
const y = startPos.y + (endPos.y - startPos.y) * ease; const t = isHit ? easeOutBounce(raw) : easeOutBack(raw);
// Arc for hits (pieces going to bar) const x = startPos.x + (endPos.x - startPos.x) * t;
let arcY = 0; const y = startPos.y + (endPos.y - startPos.y) * t;
if (to === 'bar') {
arcY = -40 * Math.sin(t * Math.PI);
}
onFrame({ x, y: y + arcY, progress: t }); // Parabolic arc
const arcY = -arcHeight * 4 * raw * (1 - raw);
if (t < 1) { // Scale pulse on landing
const scale = raw > 0.85 ? 1 + (1 - raw) * 2 * 0.15 : 1;
onFrame({ x, y: y + arcY, progress: raw, scale, isLanding: raw > 0.95 });
if (raw < 1) {
requestAnimationFrame(tick); requestAnimationFrame(tick);
} else { } else {
onComplete(); onComplete();
} }
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
function getPixelPos(location, layout) { export function createHitAnimation(hitPos, onFrame, onComplete) {
if (!layout) return null; const startTime = performance.now();
const { margin, pointW, pointH, barWidth, barX, boardH, bearX, bearOffWidth } = layout; const duration = 300;
if (location === 'bar') { function tick(now) {
return { x: barX + barWidth / 2, y: margin + boardH / 2 }; 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') { function tick(now) {
return { x: bearX + bearOffWidth / 2, y: margin + boardH / 2 }; 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) { if (typeof location === 'number' && location >= 0 && location <= 23) {
const { x, y, isTop } = getPointCoords(location, margin, pointW, pointH, barWidth, boardH); const { x, y, isTop } = getPointCoords(location, margin, pointW, pointH, barWidth, boardH);
return { return { x: x + pointW / 2, y: isTop ? y + checkerR * 2 : y - checkerR * 2 };
x: x + pointW / 2,
y: isTop ? y + 20 : y - 20
};
} }
return null; 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);
}
This diff is collapsed.
...@@ -3,6 +3,8 @@ import * as audio from '../../../core/audio.js'; ...@@ -3,6 +3,8 @@ import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
let confettiRaf = null;
export function mountResult(el, params) { export function mountResult(el, params) {
const { winner = 'you', scores = [0, 0], matchLength = 3, mode = 'bot', reason = '' } = params || {}; const { winner = 'you', scores = [0, 0], matchLength = 3, mode = 'bot', reason = '' } = params || {};
const didWin = winner === 'you'; const didWin = winner === 'you';
...@@ -11,21 +13,29 @@ export function mountResult(el, params) { ...@@ -11,21 +13,29 @@ export function mountResult(el, params) {
el.innerHTML = ` el.innerHTML = `
<div class="bgr-wrap"> <div class="bgr-wrap">
<canvas class="bgr-confetti" id="confetti-canvas"></canvas>
<div class="bgr-content">
<div class="bgr-hero"> <div class="bgr-hero">
<div class="bgr-trophy">${didWin ? emoji('trophy', '🏆', 64) : emoji('pensive', '😔', 64)}</div> <div class="bgr-trophy-ring ${didWin ? 'bgr-win-ring' : 'bgr-lose-ring'}">
<h1 class="bgr-title">${didWin ? 'مبروك! فزت!' : 'خسرت هالمرة'}</h1> <div class="bgr-trophy">${didWin ? emoji('trophy', '🏆', 56) : emoji('pensive', '😔', 56)}</div>
${reason === 'abandon' ? '<p class="bgr-reason">الخصم انسحب</p>' : ''} </div>
<h1 class="bgr-title">${didWin ? 'مبروك! فزت بالماتش!' : 'خسرت هالمرة...'}</h1>
<p class="bgr-subtitle">${reason === 'abandon' ? 'الخصم انسحب من اللعبة' : didWin ? 'أداء ممتاز!' : 'حاول مرة ثانية!'}</p>
</div> </div>
<div class="bgr-scoreboard"> <div class="bgr-scoreboard">
<div class="bgr-score-row"> <div class="bgr-score-col">
<span class="bgr-score-label">أنت</span> <div class="bgr-score-val ${didWin ? 'bgr-val-win' : ''}">${scores[0]}</div>
<span class="bgr-score-val ${didWin ? 'bgr-score-win' : ''}">${scores[0]}</span> <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>
<div class="bgr-score-divider">من ${matchLength}</div> <div class="bgr-score-col">
<div class="bgr-score-row"> <div class="bgr-score-val ${!didWin ? 'bgr-val-win' : ''}">${scores[1]}</div>
<span class="bgr-score-label">الخصم</span> <div class="bgr-score-label">الخصم</div>
<span class="bgr-score-val ${!didWin ? 'bgr-score-win' : ''}">${scores[1]}</span>
</div> </div>
</div> </div>
...@@ -38,54 +48,141 @@ export function mountResult(el, params) { ...@@ -38,54 +48,141 @@ export function mountResult(el, params) {
</button> </button>
</div> </div>
</div> </div>
</div>
${getResultStyles()} ${getResultStyles()}
`; `;
el.querySelector('#btn-rematch').onclick = () => { el.querySelector('#btn-rematch').onclick = () => {
audio.play('click'); audio.play('click');
stopConfetti();
scene.replace('backgammon-room', { mode: 'menu' }); scene.replace('backgammon-room', { mode: 'menu' });
}; };
el.querySelector('#btn-exit').onclick = () => { el.querySelector('#btn-exit').onclick = () => {
audio.play('click'); audio.play('click');
stopConfetti();
scene.exitGameMode(); scene.exitGameMode();
bus.emit('navigate', { world: 'play', scene: 'play-table' }); bus.emit('navigate', { world: 'play', scene: 'play-table' });
}; };
if (didWin) startConfetti(el.querySelector('#confetti-canvas'));
} }
function getResultStyles() { function startConfetti(canvas) {
return `<style> if (!canvas) return;
.bgr-wrap { const ctx = canvas.getContext('2d');
display:flex;flex-direction:column;align-items:center;justify-content:center; const w = canvas.parentElement.clientWidth;
height:100%;padding:24px; const h = canvas.parentElement.clientHeight;
background:linear-gradient(180deg,#0a0f14 0%,#101820 50%,#141f2c 100%); 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'
});
} }
.bgr-hero { text-align:center;margin-bottom:32px; }
.bgr-trophy { font-size:64px;margin-bottom:16px;animation:bgFloat 3s ease-in-out infinite; } function draw() {
.bgr-title { font-size:24px;font-weight:800;color:#f8fafc;margin:0; } ctx.clearRect(0, 0, w, h);
.bgr-reason { font-size:13px;color:#94a3b8;margin:8px 0 0; } for (const c of confetti) {
c.x += c.vx;
.bgr-scoreboard { c.y += c.vy;
display:flex;align-items:center;gap:16px; c.rot += c.vr;
padding:20px 32px;border-radius:16px; c.vx += (Math.random() - 0.5) * 0.1;
background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06); if (c.y > h + 20) { c.y = -20; c.x = Math.random() * w; }
margin-bottom:32px;
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();
} }
.bgr-score-row { display:flex;flex-direction:column;align-items:center;gap:4px; } ctx.restore();
.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); } confettiRaf = requestAnimationFrame(draw);
.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; } draw();
}
function stopConfetti() {
if (confettiRaf) { cancelAnimationFrame(confettiRaf); confettiRaf = null; }
}
@keyframes bgFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } function getResultStyles() {
</style>`; return `<style>
.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; }
.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) { export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) {
let existing = container.querySelector('.bgg-cube-display'); let existing = container.querySelector('.bgg-cube-display');
if (!existing) { if (!existing) {
...@@ -17,7 +17,26 @@ export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) { ...@@ -17,7 +17,26 @@ export function renderCubeDisplay(container, cubeValue, cubeOwner, myColor) {
existing.innerHTML = ` existing.innerHTML = `
<div class="bgg-cube bgg-cube-${position}"> <div class="bgg-cube bgg-cube-${position}">
<div class="bgg-cube-face">
<span class="bgg-cube-val">${cubeValue}</span> <span class="bgg-cube-val">${cubeValue}</span>
</div> </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 audio from '../../../core/audio.js';
import * as net from '../../../core/net.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) { export function showHints(validMoves, selectedPieceId) {
if (!selectedPieceId) return []; if (!selectedPieceId) return [];
return validMoves return validMoves
.filter(m => m.piece.id === selectedPieceId) .filter(m => m.piece.id === selectedPieceId)
.map(m => m.to); .map(m => m.to === 'off' ? 'off' : m.to);
} }
export function clearHints() { export function clearHints() {
return []; 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