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

feat: ludo — 60fps render loop with offscreen board cache + smooth tween animations

- Offscreen canvas caches static board (drawn once, reused every frame)
- Continuous requestAnimationFrame loop renders pieces at 60fps
- Piece movement uses smooth parabolic arc tweens (no setTimeout stepping)
- Enter-from-home uses easeOutBounce for natural pop-in feel
- All drawBoard() calls now just mark board dirty — next frame picks it up
- Move preview ghosts pulse smoothly using sin wave
- Ambient corner glow rendered per-frame (lightweight, no cache needed)
- Render loop auto-stops on game exit/end for zero resource leak
- Bot step animation also uses smooth tween system
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2e3f0ecf
...@@ -16,6 +16,12 @@ import * as net from '../../../core/net.js'; ...@@ -16,6 +16,12 @@ import * as net from '../../../core/net.js';
let game, validMoves, ctx, canvas, boardSize, cellSize; let game, validMoves, ctx, canvas, boardSize, cellSize;
let diceAnimating = false; let diceAnimating = false;
// 60fps animation system
let boardCanvas = null; // offscreen cached static board
let boardDirty = true; // flag to redraw static board
let animFrame = null; // rAF handle
let pieceAnims = new Map(); // pieceId -> {fromX,fromY,toX,toY,t,duration}
// Order: Red(BL), Green(TL), Yellow(TR), Blue(BR) — standard Ludo layout // Order: Red(BL), Green(TL), Yellow(TR), Blue(BR) — standard Ludo layout
const COLORS = ['#E53935', '#43A047', '#FDD835', '#1E88E5']; const COLORS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
const COLORS_LIGHT = ['#EF9A9A', '#A5D6A7', '#FFF59D', '#90CAF9']; const COLORS_LIGHT = ['#EF9A9A', '#A5D6A7', '#FFF59D', '#90CAF9'];
...@@ -130,12 +136,20 @@ export function mountGame(el, params) { ...@@ -130,12 +136,20 @@ export function mountGame(el, params) {
c.style.cssText = 'border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.4);max-width:100%;max-height:100%;'; c.style.cssText = 'border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.4);max-width:100%;max-height:100%;';
canvas = c; ctx = cx; canvas = c; ctx = cx;
// Create offscreen canvas for static board cache
boardCanvas = document.createElement('canvas');
boardCanvas.width = boardSize;
boardCanvas.height = boardSize;
boardDirty = true;
pieceAnims = new Map();
renderDiceFace(el.querySelector('#dice-box'), 1); renderDiceFace(el.querySelector('#dice-box'), 1);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const md = el.querySelector(`#dice-${i}`); const md = el.querySelector(`#dice-${i}`);
if (md) { renderMiniDice(md, 1); md.className = 'pp-dice'; } if (md) { renderMiniDice(md, 1); md.className = 'pp-dice'; }
} }
drawBoard(); drawBoard();
startRenderLoop();
updatePanels(el); updatePanels(el);
el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el)); el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el));
el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el)); el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el));
...@@ -345,25 +359,17 @@ async function botLoop(el) { ...@@ -345,25 +359,17 @@ async function botLoop(el) {
const toPos = move.to; const toPos = move.to;
if (move.type !== 'enter' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) { if (move.type !== 'enter' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) {
const hopDelay = game.turboMode ? 40 : 80; const hopDuration = game.turboMode ? 120 : 200;
const bounceHeight = cellSize * 0.5; const bounceHeight = cellSize * 0.6;
for (let i = 1; i <= toPos - fromPos; i++) { for (let i = 1; i <= toPos - fromPos; i++) {
piece.pos = fromPos + i; piece.pos = fromPos + i;
piece._bounceOffset = -bounceHeight; await tween(hopDuration, t => {
drawBoard(); const arc = -4 * bounceHeight * t * (t - 1);
await new Promise(r => setTimeout(r, hopDelay)); const squash = t > 0.8 ? (t - 0.8) * 5 * 2 : 0;
piece._bounceOffset = -bounceHeight * 1.2; piece._bounceOffset = -arc + squash;
drawBoard(); });
await new Promise(r => setTimeout(r, hopDelay * 0.6));
piece._bounceOffset = -bounceHeight * 0.4;
drawBoard();
await new Promise(r => setTimeout(r, hopDelay * 0.6));
piece._bounceOffset = 2;
drawBoard();
audio.play('move', 'game');
await new Promise(r => setTimeout(r, hopDelay * 0.4));
piece._bounceOffset = 0; piece._bounceOffset = 0;
drawBoard(); audio.play('move', 'game');
} }
piece.pos = fromPos; piece.pos = fromPos;
} }
...@@ -585,6 +591,30 @@ function waitForPieceSelection(el, moves) { ...@@ -585,6 +591,30 @@ function waitForPieceSelection(el, moves) {
canvas.addEventListener('click', handler); canvas.addEventListener('click', handler);
} }
// Smooth 60fps tween helper — runs a function with t=[0,1] each frame
function tween(duration, fn) {
return new Promise(resolve => {
const start = performance.now();
function tick() {
const elapsed = performance.now() - start;
const t = Math.min(elapsed / duration, 1);
fn(t);
if (t < 1) requestAnimationFrame(tick);
else resolve();
}
requestAnimationFrame(tick);
});
}
// Easing functions
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
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;
}
async function animateMove(el, move) { async function animateMove(el, move) {
const pIdx = parseInt(move.pieceId.split('-')[0]); const pIdx = parseInt(move.pieceId.split('-')[0]);
const pieceIdx = parseInt(move.pieceId.split('-')[1]); const pieceIdx = parseInt(move.pieceId.split('-')[1]);
...@@ -593,23 +623,16 @@ async function animateMove(el, move) { ...@@ -593,23 +623,16 @@ async function animateMove(el, move) {
const toPos = move.to; const toPos = move.to;
if (move.type === 'enter') { if (move.type === 'enter') {
// Pop-out from home base with bounce
rules.applyMove(game, game.currentPlayer, move); rules.applyMove(game, game.currentPlayer, move);
piece._bounceOffset = -cellSize * 0.6;
drawBoard();
audio.play('move', 'game'); audio.play('move', 'game');
juice.hapticMedium(); juice.hapticMedium();
// Bounce down // Smooth bounce-in from home
await new Promise(r => setTimeout(r, 80)); await tween(300, t => {
piece._bounceOffset = -cellSize * 0.3; const et = easeOutBounce(t);
drawBoard(); piece._bounceOffset = -cellSize * 0.7 * (1 - et);
await new Promise(r => setTimeout(r, 60)); });
piece._bounceOffset = cellSize * 0.1;
drawBoard();
await new Promise(r => setTimeout(r, 50));
piece._bounceOffset = 0; piece._bounceOffset = 0;
drawBoard(); // Landing burst
// Landing dust burst
const pos = getPiecePosition(0, pIdx, cellSize); const pos = getPiecePosition(0, pIdx, cellSize);
if (pos) { if (pos) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
...@@ -621,39 +644,27 @@ async function animateMove(el, move) { ...@@ -621,39 +644,27 @@ async function animateMove(el, move) {
return; return;
} }
// Step by step animation for moves on the path
const steps = toPos - fromPos; const steps = toPos - fromPos;
if (steps <= 0 || steps > 6) { if (steps <= 0 || steps > 6) {
rules.applyMove(game, game.currentPlayer, move); rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move); afterMove(el, move);
return; return;
} }
// Hop one square at a time with arc-bounce // Smooth hop per step — each hop is a parabolic arc at 60fps
const hopDelay = game.turboMode ? 40 : 80; const hopDuration = game.turboMode ? 120 : 200;
const bounceHeight = cellSize * 0.5; const bounceHeight = cellSize * 0.6;
for (let i = 1; i <= steps; i++) { for (let i = 1; i <= steps; i++) {
piece.pos = fromPos + i; piece.pos = fromPos + i;
// Arc up await tween(hopDuration, t => {
piece._bounceOffset = -bounceHeight; // Parabolic arc: goes up then down
drawBoard(); const arc = -4 * bounceHeight * t * (t - 1);
await new Promise(r => setTimeout(r, hopDelay)); // Add slight squash on landing (last 20% of animation)
// Peak const squash = t > 0.8 ? (t - 0.8) * 5 * 2 : 0;
piece._bounceOffset = -bounceHeight * 1.2; piece._bounceOffset = -arc + squash;
drawBoard(); });
await new Promise(r => setTimeout(r, hopDelay * 0.6));
// Arc down
piece._bounceOffset = -bounceHeight * 0.4;
drawBoard();
await new Promise(r => setTimeout(r, hopDelay * 0.6));
// Land with squash
piece._bounceOffset = 2;
drawBoard();
audio.play('move', 'game');
await new Promise(r => setTimeout(r, hopDelay * 0.4));
piece._bounceOffset = 0; piece._bounceOffset = 0;
drawBoard(); audio.play('move', 'game');
} }
// Landing effect on final square // Landing effect on final square
...@@ -663,15 +674,14 @@ async function animateMove(el, move) { ...@@ -663,15 +674,14 @@ async function animateMove(el, move) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const sx = rect.left + (pos.x / boardSize) * rect.width; const sx = rect.left + (pos.x / boardSize) * rect.width;
const sy = rect.top + (pos.y / boardSize) * rect.height; const sy = rect.top + (pos.y / boardSize) * rect.height;
juice.burst?.(sx, sy, { count: 4, colors: [COLORS[pIdx], '#fff'], size: 3, spread: 20, duration: 300 }); juice.burst?.(sx, sy, { count: 5, colors: [COLORS[pIdx], '#fff'], size: 4, spread: 25, duration: 350 });
} }
juice.hapticLight(); juice.hapticLight();
} }
// Re-apply via rules to handle captures properly // Apply via rules for capture logic
piece.pos = fromPos; piece.pos = fromPos;
rules.applyMove(game, game.currentPlayer, move); rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move); afterMove(el, move);
} }
...@@ -754,97 +764,168 @@ function afterMove(el, move) { ...@@ -754,97 +764,168 @@ function afterMove(el, move) {
} }
// ===== END PIECE SELECTION + ANIMATION ===== // ===== END PIECE SELECTION + ANIMATION =====
function drawBoard() { function startRenderLoop() {
if (animFrame) return;
const loop = () => {
renderFrame();
animFrame = requestAnimationFrame(loop);
};
animFrame = requestAnimationFrame(loop);
}
function stopRenderLoop() {
if (animFrame) { cancelAnimationFrame(animFrame); animFrame = null; }
}
function renderFrame() {
const cs = cellSize; const cs = cellSize;
if (!ctx || !boardCanvas) return;
// Draw cached static board
if (boardDirty) {
drawStaticBoard();
boardDirty = false;
}
clear(ctx, boardSize, boardSize); clear(ctx, boardSize, boardSize);
ctx.drawImage(boardCanvas, 0, 0);
// === Ambient corner glow (animated, lightweight) ===
if (game && !game.gameOver) {
const cp = game.currentPlayer;
const corners = [[0,boardSize],[0,0],[boardSize,0],[boardSize,boardSize]];
const [gx, gy] = corners[cp];
const ambGrad = ctx.createRadialGradient(gx, gy, 0, gx, gy, cs*5);
ambGrad.addColorStop(0, COLORS[cp] + '20');
ambGrad.addColorStop(1, 'transparent');
ctx.fillStyle = ambGrad;
ctx.fillRect(0, 0, boardSize, boardSize);
}
// Draw move preview ghosts
if (highlightedPieces.length > 0 && validMoves && validMoves.length > 0) {
const now = performance.now();
const pulse = 0.2 + Math.sin(now * 0.004) * 0.1;
validMoves.forEach(move => {
const pIdx = parseInt(move.pieceId.split('-')[0]);
const destPos = getPiecePosition(move.to, pIdx, cs);
if (destPos) {
const r = cs * 0.42;
ctx.beginPath();
ctx.arc(destPos.x, destPos.y, r, 0, Math.PI * 2);
ctx.globalAlpha = pulse;
ctx.fillStyle = COLORS[pIdx];
ctx.fill();
ctx.globalAlpha = 0.6;
ctx.setLineDash([4, 3]);
ctx.strokeStyle = COLORS[pIdx];
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1.0;
}
});
}
// Draw pieces with smooth interpolation
drawPieces(cs);
}
function drawBoard() {
boardDirty = true;
}
function drawStaticBoard() {
const cs = cellSize;
const sctx = boardCanvas.getContext('2d');
sctx.clearRect(0, 0, boardSize, boardSize);
// === ENHANCEMENT 1: Rich board background with subtle texture === // === ENHANCEMENT 1: Rich board background with subtle texture ===
const bgGrad = ctx.createRadialGradient(boardSize/2, boardSize/2, 0, boardSize/2, boardSize/2, boardSize*0.7); const bgGrad = sctx.createRadialGradient(boardSize/2, boardSize/2, 0, boardSize/2, boardSize/2, boardSize*0.7);
bgGrad.addColorStop(0, '#FFFFF8'); bgGrad.addColorStop(0, '#FFFFF8');
bgGrad.addColorStop(1, '#F0EDE6'); bgGrad.addColorStop(1, '#F0EDE6');
ctx.fillStyle = bgGrad; sctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, boardSize, boardSize); sctx.fillRect(0, 0, boardSize, boardSize);
// === ENHANCEMENT 10: Board border frame with emboss === // === ENHANCEMENT 10: Board border frame with emboss ===
ctx.save(); sctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.3)'; sctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 6; sctx.shadowBlur = 6;
ctx.shadowOffsetX = 0; sctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 2; sctx.shadowOffsetY = 2;
ctx.strokeStyle = '#8B7355'; sctx.strokeStyle = '#8B7355';
ctx.lineWidth = 3; sctx.lineWidth = 3;
ctx.strokeRect(1.5, 1.5, boardSize-3, boardSize-3); sctx.strokeRect(1.5, 1.5, boardSize-3, boardSize-3);
ctx.restore(); sctx.restore();
// Inner gold border // Inner gold border
ctx.strokeStyle = '#D4A843'; sctx.strokeStyle = '#D4A843';
ctx.lineWidth = 1.5; sctx.lineWidth = 1.5;
ctx.strokeRect(4, 4, boardSize-8, boardSize-8); sctx.strokeRect(4, 4, boardSize-8, boardSize-8);
// === ENHANCEMENT 6: Home zones with gradient fill === // === ENHANCEMENT 6: Home zones with gradient fill ===
[[0,0,9],[1,0,0],[2,9,0],[3,9,9]].forEach(([p,c,r]) => { [[0,0,9],[1,0,0],[2,9,0],[3,9,9]].forEach(([p,c,r]) => {
const hx = c*cs, hy = r*cs, hw = 6*cs; const hx = c*cs, hy = r*cs, hw = 6*cs;
const homeGrad = ctx.createLinearGradient(hx, hy, hx+hw, hy+hw); const homeGrad = sctx.createLinearGradient(hx, hy, hx+hw, hy+hw);
homeGrad.addColorStop(0, COLORS[p]); homeGrad.addColorStop(0, COLORS[p]);
homeGrad.addColorStop(1, COLORS_LIGHT[p]); homeGrad.addColorStop(1, COLORS_LIGHT[p]);
ctx.fillStyle = homeGrad; sctx.fillStyle = homeGrad;
ctx.fillRect(hx, hy, hw, hw); sctx.fillRect(hx, hy, hw, hw);
// Inner white area with subtle shadow // Inner white area with subtle shadow
const ins = cs*0.85; const ins = cs*0.85;
ctx.save(); sctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.2)'; sctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 4; sctx.shadowBlur = 4;
ctx.shadowInset = true; sctx.shadowInset = true;
ctx.fillStyle = '#FEFEFA'; sctx.fillStyle = '#FEFEFA';
ctx.fillRect(hx+ins, hy+ins, hw-ins*2, hw-ins*2); sctx.fillRect(hx+ins, hy+ins, hw-ins*2, hw-ins*2);
ctx.restore(); sctx.restore();
// Decorative inner border with rounded feel // Decorative inner border with rounded feel
ctx.strokeStyle = COLORS[p]; sctx.strokeStyle = COLORS[p];
ctx.lineWidth = 2.5; sctx.lineWidth = 2.5;
ctx.strokeRect(hx+ins+2, hy+ins+2, hw-ins*2-4, hw-ins*2-4); sctx.strokeRect(hx+ins+2, hy+ins+2, hw-ins*2-4, hw-ins*2-4);
// Corner dots decoration // Corner dots decoration
const dotR = cs * 0.15; const dotR = cs * 0.15;
ctx.fillStyle = COLORS[p]; sctx.fillStyle = COLORS[p];
[[hx+ins+8,hy+ins+8],[hx+hw-ins-8,hy+ins+8],[hx+ins+8,hy+hw-ins-8],[hx+hw-ins-8,hy+hw-ins-8]].forEach(([dx,dy]) => { [[hx+ins+8,hy+ins+8],[hx+hw-ins-8,hy+ins+8],[hx+ins+8,hy+hw-ins-8],[hx+hw-ins-8,hy+hw-ins-8]].forEach(([dx,dy]) => {
ctx.beginPath(); ctx.arc(dx, dy, dotR, 0, Math.PI*2); ctx.fill(); sctx.beginPath(); sctx.arc(dx, dy, dotR, 0, Math.PI*2); sctx.fill();
}); });
}); });
// Cross paths with subtle warmth // Cross paths with subtle warmth
ctx.fillStyle = '#FDFCF9'; sctx.fillStyle = '#FDFCF9';
ctx.fillRect(6*cs,0,3*cs,6*cs); sctx.fillRect(6*cs,0,3*cs,6*cs);
ctx.fillRect(6*cs,9*cs,3*cs,6*cs); sctx.fillRect(6*cs,9*cs,3*cs,6*cs);
ctx.fillRect(0,6*cs,6*cs,3*cs); sctx.fillRect(0,6*cs,6*cs,3*cs);
ctx.fillRect(9*cs,6*cs,6*cs,3*cs); sctx.fillRect(9*cs,6*cs,6*cs,3*cs);
ctx.fillRect(6*cs,6*cs,3*cs,3*cs); sctx.fillRect(6*cs,6*cs,3*cs,3*cs);
// === ENHANCEMENT 5: Grid lines with depth === // === ENHANCEMENT 5: Grid lines with depth ===
ctx.strokeStyle = 'rgba(0,0,0,0.08)'; sctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 0.5; sctx.lineWidth = 0.5;
for (let i = 0; i <= 15; i++) { for (let i = 0; i <= 15; i++) {
if (i >= 6 && i <= 9) { ctx.beginPath(); ctx.moveTo(i*cs,0); ctx.lineTo(i*cs,boardSize); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0,i*cs); ctx.lineTo(boardSize,i*cs); ctx.stroke(); } if (i >= 6 && i <= 9) { sctx.beginPath(); sctx.moveTo(i*cs,0); sctx.lineTo(i*cs,boardSize); sctx.stroke(); sctx.beginPath(); sctx.moveTo(0,i*cs); sctx.lineTo(boardSize,i*cs); sctx.stroke(); }
} }
for (let i = 0; i <= 6; i++) { for (let i = 0; i <= 6; i++) {
ctx.beginPath(); ctx.moveTo(6*cs,i*cs); ctx.lineTo(9*cs,i*cs); ctx.stroke(); sctx.beginPath(); sctx.moveTo(6*cs,i*cs); sctx.lineTo(9*cs,i*cs); sctx.stroke();
ctx.beginPath(); ctx.moveTo(6*cs,(i+9)*cs); ctx.lineTo(9*cs,(i+9)*cs); ctx.stroke(); sctx.beginPath(); sctx.moveTo(6*cs,(i+9)*cs); sctx.lineTo(9*cs,(i+9)*cs); sctx.stroke();
ctx.beginPath(); ctx.moveTo(i*cs,6*cs); ctx.lineTo(i*cs,9*cs); ctx.stroke(); sctx.beginPath(); sctx.moveTo(i*cs,6*cs); sctx.lineTo(i*cs,9*cs); sctx.stroke();
ctx.beginPath(); ctx.moveTo((i+9)*cs,6*cs); ctx.lineTo((i+9)*cs,9*cs); ctx.stroke(); sctx.beginPath(); sctx.moveTo((i+9)*cs,6*cs); sctx.lineTo((i+9)*cs,9*cs); sctx.stroke();
} }
// === ENHANCEMENT 5: Home columns with gradient cells === // === ENHANCEMENT 5: Home columns with gradient cells ===
for (let p = 0; p < 4; p++) { for (let p = 0; p < 4; p++) {
HOME_COLUMNS[p].forEach(([col,row], i) => { HOME_COLUMNS[p].forEach(([col,row], i) => {
const cellGrad = ctx.createLinearGradient(col*cs, row*cs, col*cs+cs, row*cs+cs); const cellGrad = sctx.createLinearGradient(col*cs, row*cs, col*cs+cs, row*cs+cs);
cellGrad.addColorStop(0, COLORS_LIGHT[p]); cellGrad.addColorStop(0, COLORS_LIGHT[p]);
cellGrad.addColorStop(1, COLORS[p] + '40'); cellGrad.addColorStop(1, COLORS[p] + '40');
ctx.fillStyle = cellGrad; sctx.fillStyle = cellGrad;
ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2); sctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
// Arrow indicator toward center // Arrow indicator toward center
ctx.fillStyle = COLORS[p] + '60'; sctx.fillStyle = COLORS[p] + '60';
ctx.font = `${cs*0.35}px sans-serif`; sctx.font = `${cs*0.35}px sans-serif`;
ctx.textAlign = 'center'; sctx.textAlign = 'center';
ctx.textBaseline = 'middle'; sctx.textBaseline = 'middle';
const arrows = ['→','↓','←','↑']; const arrows = ['→','↓','←','↑'];
ctx.fillText(arrows[p], col*cs+cs/2, row*cs+cs/2); sctx.fillText(arrows[p], col*cs+cs/2, row*cs+cs/2);
}); });
} }
...@@ -852,21 +933,21 @@ function drawBoard() { ...@@ -852,21 +933,21 @@ function drawBoard() {
SAFE_SQUARES.forEach(idx => { SAFE_SQUARES.forEach(idx => {
const [col,row] = SHARED_PATH[idx]; const [col,row] = SHARED_PATH[idx];
// Glow background // Glow background
const safeGrad = ctx.createRadialGradient(col*cs+cs/2, row*cs+cs/2, 0, col*cs+cs/2, row*cs+cs/2, cs*0.6); const safeGrad = sctx.createRadialGradient(col*cs+cs/2, row*cs+cs/2, 0, col*cs+cs/2, row*cs+cs/2, cs*0.6);
safeGrad.addColorStop(0, '#FFF9C4'); safeGrad.addColorStop(0, '#FFF9C4');
safeGrad.addColorStop(1, '#FFF176'); safeGrad.addColorStop(1, '#FFF176');
ctx.fillStyle = safeGrad; sctx.fillStyle = safeGrad;
ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2); sctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
// Star with shadow // Star with shadow
ctx.save(); sctx.save();
ctx.shadowColor = '#F9A825'; sctx.shadowColor = '#F9A825';
ctx.shadowBlur = 4; sctx.shadowBlur = 4;
ctx.fillStyle = '#F57F17'; sctx.fillStyle = '#F57F17';
ctx.font = `bold ${cs*0.5}px sans-serif`; sctx.font = `bold ${cs*0.5}px sans-serif`;
ctx.textAlign = 'center'; sctx.textAlign = 'center';
ctx.textBaseline = 'middle'; sctx.textBaseline = 'middle';
ctx.fillText('★', col*cs+cs/2, row*cs+cs/2); sctx.fillText('★', col*cs+cs/2, row*cs+cs/2);
ctx.restore(); sctx.restore();
}); });
// === ENHANCEMENT 2: Center triangles with gradient glow === // === ENHANCEMENT 2: Center triangles with gradient glow ===
...@@ -879,88 +960,50 @@ function drawBoard() { ...@@ -879,88 +960,50 @@ function drawBoard() {
{ p: 3, pts: [[ccx+hs,ccy-hs],[ccx+hs,ccy+hs],[ccx,ccy]] }, { p: 3, pts: [[ccx+hs,ccy-hs],[ccx+hs,ccy+hs],[ccx,ccy]] },
]; ];
triPoints.forEach(({p, pts}) => { triPoints.forEach(({p, pts}) => {
const grad = ctx.createRadialGradient(ccx, ccy, 0, ccx, ccy, hs); const grad = sctx.createRadialGradient(ccx, ccy, 0, ccx, ccy, hs);
grad.addColorStop(0, '#fff'); grad.addColorStop(0, '#fff');
grad.addColorStop(0.3, COLORS_LIGHT[p]); grad.addColorStop(0.3, COLORS_LIGHT[p]);
grad.addColorStop(1, COLORS[p]); grad.addColorStop(1, COLORS[p]);
ctx.fillStyle = grad; sctx.fillStyle = grad;
ctx.beginPath(); sctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]); sctx.moveTo(pts[0][0], pts[0][1]);
ctx.lineTo(pts[1][0], pts[1][1]); sctx.lineTo(pts[1][0], pts[1][1]);
ctx.lineTo(pts[2][0], pts[2][1]); sctx.lineTo(pts[2][0], pts[2][1]);
ctx.closePath(); sctx.closePath();
ctx.fill(); sctx.fill();
}); });
// Center diamond highlight // Center diamond highlight
ctx.save(); sctx.save();
ctx.shadowColor = 'rgba(255,255,255,0.8)'; sctx.shadowColor = 'rgba(255,255,255,0.8)';
ctx.shadowBlur = 6; sctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(255,255,255,0.6)'; sctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.beginPath(); sctx.beginPath();
ctx.arc(ccx, ccy, cs*0.3, 0, Math.PI*2); sctx.arc(ccx, ccy, cs*0.3, 0, Math.PI*2);
ctx.fill(); sctx.fill();
ctx.restore(); sctx.restore();
// Outer border of center // Outer border of center
ctx.strokeStyle = 'rgba(255,255,255,0.9)'; sctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2; sctx.lineWidth = 2;
ctx.strokeRect(ccx-hs, ccy-hs, hs*2, hs*2); sctx.strokeRect(ccx-hs, ccy-hs, hs*2, hs*2);
// Start position colored squares with glow // Start position colored squares with glow
const startColors = [[6,13,'#FFCDD2'],[1,6,'#C8E6C9'],[8,1,'#FFF9C4'],[13,8,'#BBDEFB']]; const startColors = [[6,13,'#FFCDD2'],[1,6,'#C8E6C9'],[8,1,'#FFF9C4'],[13,8,'#BBDEFB']];
startColors.forEach(([col,row,color]) => { startColors.forEach(([col,row,color]) => {
ctx.fillStyle = color; sctx.fillStyle = color;
ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2); sctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
// Arrow showing entry direction // Arrow showing entry direction
ctx.fillStyle = 'rgba(0,0,0,0.2)'; sctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.font = `${cs*0.4}px sans-serif`; sctx.font = `${cs*0.4}px sans-serif`;
ctx.textAlign = 'center'; sctx.textAlign = 'center';
ctx.textBaseline = 'middle'; sctx.textBaseline = 'middle';
ctx.fillText('▶', col*cs+cs/2, row*cs+cs/2); sctx.fillText('▶', col*cs+cs/2, row*cs+cs/2);
}); });
// === ENHANCEMENT 8: Ambient corner glow for current player === }
if (game && !game.gameOver) {
const cp = game.currentPlayer;
const corners = [[0,boardSize],[0,0],[boardSize,0],[boardSize,boardSize]];
const [gx, gy] = corners[cp];
const ambGrad = ctx.createRadialGradient(gx, gy, 0, gx, gy, cs*5);
ambGrad.addColorStop(0, COLORS[cp] + '25');
ambGrad.addColorStop(1, 'transparent');
ctx.fillStyle = ambGrad;
ctx.fillRect(0, 0, boardSize, boardSize);
}
// Move preview — draw ghost circles at destination for highlighted pieces
if (highlightedPieces.length > 0 && validMoves && validMoves.length > 0) {
validMoves.forEach(move => {
const pIdx = parseInt(move.pieceId.split('-')[0]);
let destPos;
if (move.type === 'enter') {
destPos = getPiecePosition(move.to, pIdx, cs);
} else {
destPos = getPiecePosition(move.to, pIdx, cs);
}
if (destPos) {
const r = cs * 0.42;
ctx.beginPath();
ctx.arc(destPos.x, destPos.y, r, 0, Math.PI * 2);
ctx.globalAlpha = 0.3;
ctx.fillStyle = COLORS[pIdx];
ctx.fill();
ctx.globalAlpha = 1.0;
// Dashed outline
ctx.setLineDash([3, 3]);
ctx.strokeStyle = COLORS[pIdx];
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.setLineDash([]);
}
});
}
// Pieces — with highlight for selectable ones function drawPieces(cs) {
// First, group pieces by their cell position to handle stacking // Group pieces by their cell position to handle stacking
const cellOccupants = new Map(); // key: "x,y" -> [{pIdx, pieceIdx, piece}] const cellOccupants = new Map();
game.players.forEach((player, pIdx) => { game.players.forEach((player, pIdx) => {
player.pieces.forEach((piece, pieceIdx) => { player.pieces.forEach((piece, pieceIdx) => {
if (piece.finished) return; if (piece.finished) return;
...@@ -1178,6 +1221,7 @@ function handleExit(el) { ...@@ -1178,6 +1221,7 @@ function handleExit(el) {
if (!confirm('هل تريد الخروج من المباراة؟')) return; if (!confirm('هل تريد الخروج من المباراة؟')) return;
game.gameOver = true; game.gameOver = true;
stopRenderLoop();
audio.play('click'); audio.play('click');
if (game.mode === 'live' && matchId) { if (game.mode === 'live' && matchId) {
...@@ -1194,6 +1238,7 @@ function handleExit(el) { ...@@ -1194,6 +1238,7 @@ function handleExit(el) {
function endGame(el) { function endGame(el) {
game.gameOver = true; game.gameOver = true;
stopRenderLoop();
if (matchId) matchLive.session?.destroy?.(); if (matchId) matchLive.session?.destroy?.();
const myRank = game.winners.indexOf(myPlayerIndex); const myRank = game.winners.indexOf(myPlayerIndex);
......
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