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';
let game, validMoves, ctx, canvas, boardSize, cellSize;
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
const COLORS = ['#E53935', '#43A047', '#FDD835', '#1E88E5'];
const COLORS_LIGHT = ['#EF9A9A', '#A5D6A7', '#FFF59D', '#90CAF9'];
......@@ -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%;';
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);
for (let i = 0; i < 4; i++) {
const md = el.querySelector(`#dice-${i}`);
if (md) { renderMiniDice(md, 1); md.className = 'pp-dice'; }
}
drawBoard();
startRenderLoop();
updatePanels(el);
el.querySelector('#roll-btn').addEventListener('click', () => handleRoll(el));
el.querySelector('#exit-btn').addEventListener('click', () => handleExit(el));
......@@ -345,25 +359,17 @@ async function botLoop(el) {
const toPos = move.to;
if (move.type !== 'enter' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) {
const hopDelay = game.turboMode ? 40 : 80;
const bounceHeight = cellSize * 0.5;
const hopDuration = game.turboMode ? 120 : 200;
const bounceHeight = cellSize * 0.6;
for (let i = 1; i <= toPos - fromPos; i++) {
piece.pos = fromPos + i;
piece._bounceOffset = -bounceHeight;
drawBoard();
await new Promise(r => setTimeout(r, hopDelay));
piece._bounceOffset = -bounceHeight * 1.2;
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));
await tween(hopDuration, t => {
const arc = -4 * bounceHeight * t * (t - 1);
const squash = t > 0.8 ? (t - 0.8) * 5 * 2 : 0;
piece._bounceOffset = -arc + squash;
});
piece._bounceOffset = 0;
drawBoard();
audio.play('move', 'game');
}
piece.pos = fromPos;
}
......@@ -585,6 +591,30 @@ function waitForPieceSelection(el, moves) {
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) {
const pIdx = parseInt(move.pieceId.split('-')[0]);
const pieceIdx = parseInt(move.pieceId.split('-')[1]);
......@@ -593,23 +623,16 @@ async function animateMove(el, move) {
const toPos = move.to;
if (move.type === 'enter') {
// Pop-out from home base with bounce
rules.applyMove(game, game.currentPlayer, move);
piece._bounceOffset = -cellSize * 0.6;
drawBoard();
audio.play('move', 'game');
juice.hapticMedium();
// Bounce down
await new Promise(r => setTimeout(r, 80));
piece._bounceOffset = -cellSize * 0.3;
drawBoard();
await new Promise(r => setTimeout(r, 60));
piece._bounceOffset = cellSize * 0.1;
drawBoard();
await new Promise(r => setTimeout(r, 50));
// Smooth bounce-in from home
await tween(300, t => {
const et = easeOutBounce(t);
piece._bounceOffset = -cellSize * 0.7 * (1 - et);
});
piece._bounceOffset = 0;
drawBoard();
// Landing dust burst
// Landing burst
const pos = getPiecePosition(0, pIdx, cellSize);
if (pos) {
const rect = canvas.getBoundingClientRect();
......@@ -621,39 +644,27 @@ async function animateMove(el, move) {
return;
}
// Step by step animation for moves on the path
const steps = toPos - fromPos;
if (steps <= 0 || steps > 6) {
rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move);
return;
}
// Hop one square at a time with arc-bounce
const hopDelay = game.turboMode ? 40 : 80;
const bounceHeight = cellSize * 0.5;
// Smooth hop per step — each hop is a parabolic arc at 60fps
const hopDuration = game.turboMode ? 120 : 200;
const bounceHeight = cellSize * 0.6;
for (let i = 1; i <= steps; i++) {
piece.pos = fromPos + i;
// Arc up
piece._bounceOffset = -bounceHeight;
drawBoard();
await new Promise(r => setTimeout(r, hopDelay));
// Peak
piece._bounceOffset = -bounceHeight * 1.2;
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));
await tween(hopDuration, t => {
// Parabolic arc: goes up then down
const arc = -4 * bounceHeight * t * (t - 1);
// Add slight squash on landing (last 20% of animation)
const squash = t > 0.8 ? (t - 0.8) * 5 * 2 : 0;
piece._bounceOffset = -arc + squash;
});
piece._bounceOffset = 0;
drawBoard();
audio.play('move', 'game');
}
// Landing effect on final square
......@@ -663,15 +674,14 @@ async function animateMove(el, move) {
const rect = canvas.getBoundingClientRect();
const sx = rect.left + (pos.x / boardSize) * rect.width;
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();
}
// Re-apply via rules to handle captures properly
// Apply via rules for capture logic
piece.pos = fromPos;
rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move);
}
......@@ -754,97 +764,168 @@ function afterMove(el, move) {
}
// ===== 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;
if (!ctx || !boardCanvas) return;
// Draw cached static board
if (boardDirty) {
drawStaticBoard();
boardDirty = false;
}
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 ===
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(1, '#F0EDE6');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, boardSize, boardSize);
sctx.fillStyle = bgGrad;
sctx.fillRect(0, 0, boardSize, boardSize);
// === ENHANCEMENT 10: Board border frame with emboss ===
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 6;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 2;
ctx.strokeStyle = '#8B7355';
ctx.lineWidth = 3;
ctx.strokeRect(1.5, 1.5, boardSize-3, boardSize-3);
ctx.restore();
sctx.save();
sctx.shadowColor = 'rgba(0,0,0,0.3)';
sctx.shadowBlur = 6;
sctx.shadowOffsetX = 0;
sctx.shadowOffsetY = 2;
sctx.strokeStyle = '#8B7355';
sctx.lineWidth = 3;
sctx.strokeRect(1.5, 1.5, boardSize-3, boardSize-3);
sctx.restore();
// Inner gold border
ctx.strokeStyle = '#D4A843';
ctx.lineWidth = 1.5;
ctx.strokeRect(4, 4, boardSize-8, boardSize-8);
sctx.strokeStyle = '#D4A843';
sctx.lineWidth = 1.5;
sctx.strokeRect(4, 4, boardSize-8, boardSize-8);
// === ENHANCEMENT 6: Home zones with gradient fill ===
[[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 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(1, COLORS_LIGHT[p]);
ctx.fillStyle = homeGrad;
ctx.fillRect(hx, hy, hw, hw);
sctx.fillStyle = homeGrad;
sctx.fillRect(hx, hy, hw, hw);
// Inner white area with subtle shadow
const ins = cs*0.85;
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,0.2)';
ctx.shadowBlur = 4;
ctx.shadowInset = true;
ctx.fillStyle = '#FEFEFA';
ctx.fillRect(hx+ins, hy+ins, hw-ins*2, hw-ins*2);
ctx.restore();
sctx.save();
sctx.shadowColor = 'rgba(0,0,0,0.2)';
sctx.shadowBlur = 4;
sctx.shadowInset = true;
sctx.fillStyle = '#FEFEFA';
sctx.fillRect(hx+ins, hy+ins, hw-ins*2, hw-ins*2);
sctx.restore();
// Decorative inner border with rounded feel
ctx.strokeStyle = COLORS[p];
ctx.lineWidth = 2.5;
ctx.strokeRect(hx+ins+2, hy+ins+2, hw-ins*2-4, hw-ins*2-4);
sctx.strokeStyle = COLORS[p];
sctx.lineWidth = 2.5;
sctx.strokeRect(hx+ins+2, hy+ins+2, hw-ins*2-4, hw-ins*2-4);
// Corner dots decoration
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]) => {
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
ctx.fillStyle = '#FDFCF9';
ctx.fillRect(6*cs,0,3*cs,6*cs);
ctx.fillRect(6*cs,9*cs,3*cs,6*cs);
ctx.fillRect(0,6*cs,6*cs,3*cs);
ctx.fillRect(9*cs,6*cs,6*cs,3*cs);
ctx.fillRect(6*cs,6*cs,3*cs,3*cs);
sctx.fillStyle = '#FDFCF9';
sctx.fillRect(6*cs,0,3*cs,6*cs);
sctx.fillRect(6*cs,9*cs,3*cs,6*cs);
sctx.fillRect(0,6*cs,6*cs,3*cs);
sctx.fillRect(9*cs,6*cs,6*cs,3*cs);
sctx.fillRect(6*cs,6*cs,3*cs,3*cs);
// === ENHANCEMENT 5: Grid lines with depth ===
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 0.5;
sctx.strokeStyle = 'rgba(0,0,0,0.08)';
sctx.lineWidth = 0.5;
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++) {
ctx.beginPath(); ctx.moveTo(6*cs,i*cs); ctx.lineTo(9*cs,i*cs); ctx.stroke();
ctx.beginPath(); ctx.moveTo(6*cs,(i+9)*cs); ctx.lineTo(9*cs,(i+9)*cs); ctx.stroke();
ctx.beginPath(); ctx.moveTo(i*cs,6*cs); ctx.lineTo(i*cs,9*cs); ctx.stroke();
ctx.beginPath(); ctx.moveTo((i+9)*cs,6*cs); ctx.lineTo((i+9)*cs,9*cs); ctx.stroke();
sctx.beginPath(); sctx.moveTo(6*cs,i*cs); sctx.lineTo(9*cs,i*cs); sctx.stroke();
sctx.beginPath(); sctx.moveTo(6*cs,(i+9)*cs); sctx.lineTo(9*cs,(i+9)*cs); sctx.stroke();
sctx.beginPath(); sctx.moveTo(i*cs,6*cs); sctx.lineTo(i*cs,9*cs); sctx.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 ===
for (let p = 0; p < 4; p++) {
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(1, COLORS[p] + '40');
ctx.fillStyle = cellGrad;
ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
sctx.fillStyle = cellGrad;
sctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
// Arrow indicator toward center
ctx.fillStyle = COLORS[p] + '60';
ctx.font = `${cs*0.35}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
sctx.fillStyle = COLORS[p] + '60';
sctx.font = `${cs*0.35}px sans-serif`;
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
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() {
SAFE_SQUARES.forEach(idx => {
const [col,row] = SHARED_PATH[idx];
// 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(1, '#FFF176');
ctx.fillStyle = safeGrad;
ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
sctx.fillStyle = safeGrad;
sctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
// Star with shadow
ctx.save();
ctx.shadowColor = '#F9A825';
ctx.shadowBlur = 4;
ctx.fillStyle = '#F57F17';
ctx.font = `bold ${cs*0.5}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('★', col*cs+cs/2, row*cs+cs/2);
ctx.restore();
sctx.save();
sctx.shadowColor = '#F9A825';
sctx.shadowBlur = 4;
sctx.fillStyle = '#F57F17';
sctx.font = `bold ${cs*0.5}px sans-serif`;
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
sctx.fillText('★', col*cs+cs/2, row*cs+cs/2);
sctx.restore();
});
// === ENHANCEMENT 2: Center triangles with gradient glow ===
......@@ -879,88 +960,50 @@ function drawBoard() {
{ p: 3, pts: [[ccx+hs,ccy-hs],[ccx+hs,ccy+hs],[ccx,ccy]] },
];
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.3, COLORS_LIGHT[p]);
grad.addColorStop(1, COLORS[p]);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
ctx.lineTo(pts[1][0], pts[1][1]);
ctx.lineTo(pts[2][0], pts[2][1]);
ctx.closePath();
ctx.fill();
sctx.fillStyle = grad;
sctx.beginPath();
sctx.moveTo(pts[0][0], pts[0][1]);
sctx.lineTo(pts[1][0], pts[1][1]);
sctx.lineTo(pts[2][0], pts[2][1]);
sctx.closePath();
sctx.fill();
});
// Center diamond highlight
ctx.save();
ctx.shadowColor = 'rgba(255,255,255,0.8)';
ctx.shadowBlur = 6;
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.beginPath();
ctx.arc(ccx, ccy, cs*0.3, 0, Math.PI*2);
ctx.fill();
ctx.restore();
sctx.save();
sctx.shadowColor = 'rgba(255,255,255,0.8)';
sctx.shadowBlur = 6;
sctx.fillStyle = 'rgba(255,255,255,0.6)';
sctx.beginPath();
sctx.arc(ccx, ccy, cs*0.3, 0, Math.PI*2);
sctx.fill();
sctx.restore();
// Outer border of center
ctx.strokeStyle = 'rgba(255,255,255,0.9)';
ctx.lineWidth = 2;
ctx.strokeRect(ccx-hs, ccy-hs, hs*2, hs*2);
sctx.strokeStyle = 'rgba(255,255,255,0.9)';
sctx.lineWidth = 2;
sctx.strokeRect(ccx-hs, ccy-hs, hs*2, hs*2);
// Start position colored squares with glow
const startColors = [[6,13,'#FFCDD2'],[1,6,'#C8E6C9'],[8,1,'#FFF9C4'],[13,8,'#BBDEFB']];
startColors.forEach(([col,row,color]) => {
ctx.fillStyle = color;
ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
sctx.fillStyle = color;
sctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2);
// Arrow showing entry direction
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.font = `${cs*0.4}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('▶', col*cs+cs/2, row*cs+cs/2);
sctx.fillStyle = 'rgba(0,0,0,0.2)';
sctx.font = `${cs*0.4}px sans-serif`;
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
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
// First, group pieces by their cell position to handle stacking
const cellOccupants = new Map(); // key: "x,y" -> [{pIdx, pieceIdx, piece}]
function drawPieces(cs) {
// Group pieces by their cell position to handle stacking
const cellOccupants = new Map();
game.players.forEach((player, pIdx) => {
player.pieces.forEach((piece, pieceIdx) => {
if (piece.finished) return;
......@@ -1178,6 +1221,7 @@ function handleExit(el) {
if (!confirm('هل تريد الخروج من المباراة؟')) return;
game.gameOver = true;
stopRenderLoop();
audio.play('click');
if (game.mode === 'live' && matchId) {
......@@ -1194,6 +1238,7 @@ function handleExit(el) {
function endGame(el) {
game.gameOver = true;
stopRenderLoop();
if (matchId) matchLive.session?.destroy?.();
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