Commit 58001eb9 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: ludo — super juicy animations, mini dice on each player panel

- Add mini dice to all player panels showing roll result with pop-in animation
- Bot/opponent dice rolls visible next to their name with same shake + land effect
- Panel glows on six, active player has pulse + avatar bounce animation
- Pawn movement uses full arc-bounce (up→peak→down→squash) per hop
- Enter animation: pop-out from home with bounce + landing dust burst
- Long moves (3+ steps) produce particle burst on landing
- Mini dice fades out after 1.2s when turn passes to next player
- Rolling state shows rotation shake animation on mini dice
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 3b54be30
......@@ -35,6 +35,7 @@ function renderPanel(p) {
<span style="font-size:11px;font-weight:600;color:#f8fafc;">${p.name}</span>
${p.level ? `<span style="font-size:10px;color:#64748b;">${p.level}</span>` : ''}
</div>
<div class="pp-dice" id="dice-${p.i}"></div>
</div>
`;
}
......@@ -96,13 +97,21 @@ export function mountGame(el, params) {
</div>
</div>
<style>
.pp{display:flex;align-items:center;gap:6px;padding:5px 10px;border-radius:8px;border:2px solid transparent;transition:border-color 0.3s;}
.pp.active{border-color:var(--pc);background:rgba(255,255,255,0.05);}
.pp{display:flex;align-items:center;gap:6px;padding:5px 10px;border-radius:8px;border:2px solid transparent;transition:all 0.3s cubic-bezier(0.34,1.56,0.64,1);}
.pp.active{border-color:var(--pc);background:rgba(255,255,255,0.05);animation:panelPulse 2s ease-in-out infinite;}
.pp-dot{width:10px;height:10px;border-radius:50%;background:var(--pc);}
.pp span{font-size:12px;font-weight:600;color:#94a3b8;}
.pp span{font-size:12px;font-weight:600;color:#94a3b8;transition:color 0.3s;}
.pp.active span{color:#f8fafc;}
.pp-dice{width:28px;height:28px;background:#f8fafc;border-radius:5px;display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr);padding:3px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transition:transform 0.15s;opacity:0.4;}
.pp.active .pp-dice{opacity:1;}
.pp-dice{width:28px;height:28px;background:#f8fafc;border-radius:5px;display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr);padding:3px;box-shadow:0 2px 6px rgba(0,0,0,0.3);transition:all 0.2s cubic-bezier(0.34,1.56,0.64,1);opacity:0;transform:scale(0);margin-left:auto;}
.pp-dice.visible{opacity:1;transform:scale(1);}
.pp-dice.rolling{animation:diceShake 0.08s infinite alternate;opacity:1;transform:scale(1);}
.pp-dice.landed{opacity:1;transform:scale(1.15);box-shadow:0 0 12px rgba(228,172,56,0.5);}
.pp.active .pp-avatar{animation:avatarBounce 2s ease-in-out infinite;}
@keyframes panelPulse{0%,100%{box-shadow:0 0 0 0 transparent;}50%{box-shadow:0 0 12px 2px color-mix(in srgb, var(--pc) 30%, transparent);}}
@keyframes avatarBounce{0%,100%{transform:scale(1);}50%{transform:scale(1.08);}}
@keyframes diceShake{from{transform:rotate(-12deg) scale(0.9);}to{transform:rotate(12deg) scale(0.9);}}
@keyframes dicePopIn{0%{transform:scale(0) rotate(-30deg);opacity:0;}60%{transform:scale(1.3) rotate(5deg);opacity:1;}100%{transform:scale(1) rotate(0deg);opacity:1;}}
@keyframes diceSix{0%,100%{box-shadow:0 0 8px rgba(228,172,56,0.4);}50%{box-shadow:0 0 18px rgba(228,172,56,0.8);}}
</style>
`;
......@@ -118,7 +127,7 @@ export function mountGame(el, params) {
renderDiceFace(el.querySelector('#dice-box'), 1);
for (let i = 0; i < 4; i++) {
const md = el.querySelector(`#dice-${i}`);
if (md) renderMiniDice(md, 1);
if (md) { renderMiniDice(md, 1); md.className = 'pp-dice'; }
}
drawBoard();
updatePanels(el);
......@@ -330,12 +339,25 @@ async function botLoop(el) {
const toPos = move.to;
if (move.type !== 'enter' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) {
const stepDelay = game.turboMode ? 50 : 100;
const hopDelay = game.turboMode ? 40 : 80;
const bounceHeight = cellSize * 0.5;
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, stepDelay));
await new Promise(r => setTimeout(r, hopDelay * 0.4));
piece._bounceOffset = 0;
drawBoard();
}
piece.pos = fromPos;
}
......@@ -565,11 +587,30 @@ async function animateMove(el, move) {
const toPos = move.to;
if (move.type === 'enter') {
// Entering from home — just one hop
// Pop-out from home base with bounce
rules.applyMove(game, game.currentPlayer, move);
piece._bounceOffset = -cellSize * 0.6;
drawBoard();
audio.play('move', 'game');
juice.hapticLight();
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));
piece._bounceOffset = 0;
drawBoard();
// Landing dust burst
const pos = getPiecePosition(0, pIdx, cellSize);
if (pos) {
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: 6, colors: [COLORS[pIdx], '#fff'], size: 4, spread: 30, duration: 400 });
}
afterMove(el, move);
return;
}
......@@ -577,29 +618,52 @@ async function animateMove(el, move) {
// Step by step animation for moves on the path
const steps = toPos - fromPos;
if (steps <= 0 || steps > 6) {
// Edge case — just apply directly
rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move);
return;
}
// Hop one square at a time with vertical bounce
const hopDelay = game.turboMode ? 30 : 60;
// Hop one square at a time with arc-bounce
const hopDelay = game.turboMode ? 40 : 80;
const bounceHeight = cellSize * 0.5;
for (let i = 1; i <= steps; i++) {
piece.pos = fromPos + i;
piece._bounceOffset = -2;
// Arc up
piece._bounceOffset = -bounceHeight;
drawBoard();
audio.play('move', 'game');
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));
piece._bounceOffset = 0;
drawBoard();
await new Promise(r => setTimeout(r, hopDelay));
}
// Apply capture/finish effects after animation
// Landing effect on final square
if (steps >= 3) {
const pos = getPiecePosition(toPos, pIdx, cellSize);
if (pos) {
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.hapticLight();
}
// Re-apply via rules to handle captures properly
piece.pos = fromPos; // Reset
piece.pos = fromPos;
rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move);
......@@ -853,8 +917,15 @@ function updatePanels(el) {
for (let i = 0; i < 4; i++) {
const p = el.querySelector(`#pp-${i}`);
if (p) {
p.classList.toggle('active', i === game.currentPlayer);
const wasActive = p.classList.contains('active');
const isNowActive = i === game.currentPlayer;
p.classList.toggle('active', isNowActive);
if (game.players[i].finished) p.style.opacity = '0.5';
// Hide dice when turn changes away from this player
if (wasActive && !isNowActive) {
const md = el.querySelector(`#dice-${i}`);
if (md) { setTimeout(() => { md.className = 'pp-dice'; md.style.animation = ''; }, 1200); }
}
}
}
const diceArea = el.querySelector('#dice-area');
......@@ -878,7 +949,22 @@ function animateDice(el, playerIdx) {
return new Promise(resolve => {
const mainDice = el.querySelector('#dice-box');
const miniDice = el.querySelector(`#dice-${playerIdx}`);
const isMe = playerIdx === myPlayerIndex;
// Show mini dice in rolling state
if (miniDice) {
miniDice.className = 'pp-dice rolling';
renderMiniDice(miniDice, 1);
}
// Show main dice for everyone (not just self)
if (mainDice) {
mainDice.style.visibility = 'visible';
mainDice.style.opacity = '1';
}
let count = 0;
const maxFrames = game.turboMode ? 8 : 14;
const shakeAnim = setInterval(() => {
const randVal = Math.floor(Math.random() * 6) + 1;
renderDiceFace(mainDice, randVal);
......@@ -887,28 +973,38 @@ function animateDice(el, playerIdx) {
const ry = (Math.random() - 0.5) * 16;
const rot = (Math.random() - 0.5) * 30;
mainDice.style.transform = `translate(${rx}px,${ry}px) rotate(${rot}deg) scale(0.9)`;
if (miniDice) miniDice.style.transform = `rotate(${rot * 0.5}deg) scale(0.85)`;
count++;
if (count > (game.turboMode ? 8 : 14)) {
if (count > maxFrames) {
clearInterval(shakeAnim);
const dice = rules.rollDice(game, game.currentPlayer);
game.diceValue = dice;
renderDiceFace(mainDice, dice);
if (miniDice) renderMiniDice(miniDice, dice);
mainDice.style.transform = 'scale(1.25)';
if (miniDice) miniDice.style.transform = 'scale(1.15)';
setTimeout(() => {
mainDice.style.transform = 'scale(1)';
if (miniDice) miniDice.style.transform = 'scale(1)';
}, 150);
// Pop-in landing on main dice
mainDice.style.transform = 'scale(1.3)';
setTimeout(() => { mainDice.style.transform = 'scale(1)'; }, 180);
// Mini dice lands with pop
if (miniDice) {
miniDice.className = 'pp-dice landed';
miniDice.style.animation = 'dicePopIn 0.35s cubic-bezier(0.34,1.56,0.64,1)';
if (dice === 6) miniDice.style.animation += ', diceSix 1s ease-in-out infinite';
}
audio.play('dice', 'game');
juice.hapticHeavy();
juice.shake(el.querySelector('#dice-area'), 3, 150);
juice.shake(el.querySelector(isMe ? '#dice-area' : `#pp-${playerIdx}`), 4, 200);
if (dice === 6) {
mainDice.style.boxShadow = '0 0 20px #E4AC38, 0 4px 14px rgba(0,0,0,0.4)';
setTimeout(() => { mainDice.style.boxShadow = '0 4px 14px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.8)'; }, 600);
setTimeout(() => { mainDice.style.boxShadow = '0 4px 14px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.8)'; }, 800);
juice.hapticSuccess();
// Flash the player panel gold on a 6
const panel = el.querySelector(`#pp-${playerIdx}`);
if (panel) panel.animate([{background:'rgba(228,172,56,0.3)'},{background:'transparent'}], {duration:600});
}
resolve(dice);
}
}, 55);
......@@ -918,8 +1014,11 @@ function animateDice(el, playerIdx) {
function renderMiniDice(miniDice, value) {
const dots = { 1:[0,0,0,0,1,0,0,0,0], 2:[0,0,1,0,0,0,1,0,0], 3:[0,0,1,0,1,0,1,0,0], 4:[1,0,1,0,0,0,1,0,1], 5:[1,0,1,0,1,0,1,0,1], 6:[1,0,1,1,0,1,1,0,1] };
const pattern = dots[value] || dots[1];
miniDice.style.display = 'grid';
miniDice.style.gridTemplate = 'repeat(3,1fr)/repeat(3,1fr)';
miniDice.style.padding = '3px';
miniDice.innerHTML = pattern.map(d =>
`<div style="width:5px;height:5px;border-radius:50%;background:${d ? '#1a1a2e' : 'transparent'};margin:auto;"></div>`
`<div style="width:5px;height:5px;border-radius:50%;background:${d ? '#1a1a2e' : 'transparent'};margin:auto;${d ? 'box-shadow:0 1px 1px rgba(0,0,0,0.2);' : ''}"></div>`
).join('');
}
......
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