Commit 6d1a8ac5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(ludo): dice visibility, capture SFX/VFX, ranking system, finish fix

- Hide dice + roll button when not player's turn; show only on active turn
- Distinct capture feedback: cheerful SFX+confetti for the capturer,
  sad SFX+red flash+shake for the victim whose pawn got sent home
- Game continues until 3 players finish — 1st/2nd/3rd get podium screen,
  4th gets loser screen. Finished players are dimmed and auto-skipped
- Fix finish position: pawns now land on the outer middle of the center
  3x3 grid (squares 2/4/6/8 per color), not the dead center square 5
- Result screen shows Arabic ranking labels and medals per placement
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent c55124cc
......@@ -70,7 +70,7 @@ export function gridToPixel(col, row, cellSize) {
}
// Get pixel position for a piece given its local position and player index
// Positions: 0-50 shared path, 51-56 home column, 57 = finish (center)
// Positions: 0-50 shared path, 51-56 home column (56 = finish on outer middle of center 3x3)
export function getPiecePosition(localPos, playerIdx, cellSize) {
if (localPos === -1) return null;
......@@ -80,8 +80,9 @@ export function getPiecePosition(localPos, playerIdx, cellSize) {
const [col, row] = HOME_COLUMNS[playerIdx][homeIdx];
return gridToPixel(col, row, cellSize);
}
// Finished — center of board
return gridToPixel(7, 7, cellSize);
// Fallback — last home column square (should not normally be reached)
const [col, row] = HOME_COLUMNS[playerIdx][5];
return gridToPixel(col, row, cellSize);
}
const globalPos = (localPos + START_SQUARES[playerIdx]) % 52;
......
const SHARED_PATH_LENGTH = 52;
const HOME_ENTRY_POS = 50;
const HOME_STRETCH = 6;
const FINISH_POS = HOME_ENTRY_POS + 1 + HOME_STRETCH; // 57
const HOME_STRETCH = 5;
const FINISH_POS = HOME_ENTRY_POS + 1 + HOME_STRETCH; // 56 — the 6th home column square (outer middle of center 3x3)
// Must match board-map.js START_SQUARES exactly
const START_POSITIONS = [0, 13, 26, 39];
const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
......@@ -188,7 +188,7 @@ export function applyMove(game, playerIdx, move) {
if (move.type === 'enter') {
piece.pos = 0;
const globalPos = START_POSITIONS[playerIdx];
captureAt(game, playerIdx, globalPos);
move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true;
} else if (move.type === 'finish') {
piece.pos = move.to;
......@@ -197,14 +197,17 @@ export function applyMove(game, playerIdx, move) {
if (player.pieces.every(p => p.finished)) {
player.finished = true;
game.winners.push(playerIdx);
if (game.winners.length >= game.numPlayers - 1) {
// Game ends when 3 players finish — the 4th is the loser
if (game.winners.length >= 3) {
const lastPlayer = game.players.findIndex((p, i) => !p.finished);
if (lastPlayer !== -1) game.winners.push(lastPlayer);
game.gameOver = true;
}
}
} else if (move.type === 'capture') {
piece.pos = move.to;
const globalPos = (move.to + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
captureAt(game, playerIdx, globalPos);
move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true;
} else if (move.type === 'home') {
piece.pos = move.to;
......@@ -254,7 +257,8 @@ function checkCapture(game, playerIdx, globalPos) {
}
function captureAt(game, playerIdx, globalPos) {
if (SAFE_SQUARES.includes(globalPos)) return;
const captured = [];
if (SAFE_SQUARES.includes(globalPos)) return captured;
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
......@@ -264,9 +268,11 @@ function captureAt(game, playerIdx, globalPos) {
const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
if (theirGlobal === globalPos) {
piece.pos = -1;
if (!captured.includes(i)) captured.push(i);
}
}
}
return captured;
}
export function getBotMove(game, playerIdx, dice) {
......
......@@ -213,7 +213,7 @@ function isMyTurn() {
}
async function handleRoll(el) {
if (diceAnimating || game.rolled || game.gameOver || !isMyTurn()) return;
if (diceAnimating || game.rolled || game.gameOver || !isMyTurn() || game.players[myPlayerIndex].finished) return;
diceAnimating = true;
updatePanels(el);
......@@ -268,7 +268,7 @@ function handleNonPlayerTurn(el) {
}
async function botLoop(el) {
if (game.gameOver || isMyTurn()) return;
if (game.gameOver || isMyTurn() || game.players[game.currentPlayer].finished) return;
// === BOT PERSONALITY PROFILES ===
const personalities = {
......@@ -343,10 +343,18 @@ async function botLoop(el) {
rules.applyMove(game, game.currentPlayer, move);
if (move.type === 'capture') {
audio.play('capture', 'game');
juice.shake(el, 3, 150);
const capturedMe = move._capturedPlayers && move._capturedPlayers.includes(myPlayerIndex);
if (capturedMe) {
audio.play('lose', 'game');
juice.hapticError();
juice.shake(el, 8, 400);
juice.screenFlash('rgba(229,57,53,0.25)', 500);
} else {
audio.play('capture', 'game');
juice.shake(el, 3, 150);
}
} else if (move.type === 'finish') {
audio.play('win', 'reward');
audio.play('notification');
const boardRect = canvas.getBoundingClientRect();
fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[game.currentPlayer]);
}
......@@ -616,22 +624,41 @@ function fireworkBurst(x, y, color) {
function afterMove(el, move) {
game.rolled = false;
const mover = game.currentPlayer;
if (move.type === 'capture') {
audio.play('capture','game');
juice.shake(el, 6, 300);
juice.hapticHeavy();
juice.confetti(window.innerWidth/2, window.innerHeight/2, 25);
juice.screenFlash('rgba(229,57,53,0.15)', 400);
if (mover === myPlayerIndex) {
// I captured someone — celebrate!
audio.play('capture', 'reward');
juice.hapticSuccess();
juice.confetti(window.innerWidth / 2, window.innerHeight / 2, 30);
juice.starBurst(window.innerWidth / 2, window.innerHeight / 2, 10);
juice.screenFlash('rgba(76,175,80,0.2)', 400);
} else {
// A bot/opponent captured — check if MY pawn was the victim
const capturedMe = move._capturedPlayers && move._capturedPlayers.includes(myPlayerIndex);
if (capturedMe) {
audio.play('lose', 'game');
juice.hapticError();
juice.shake(el, 8, 400);
juice.screenFlash('rgba(229,57,53,0.25)', 500);
} else {
audio.play('capture', 'game');
juice.shake(el, 3, 150);
}
}
} else if (move.type === 'finish') {
audio.play('win','reward');
juice.hapticSuccess();
const boardRect = canvas.getBoundingClientRect();
const centerX = boardRect.left + boardRect.width / 2;
const centerY = boardRect.top + boardRect.height / 2;
fireworkBurst(centerX, centerY, COLORS[game.currentPlayer]);
juice.screenFlash(COLORS[game.currentPlayer] + '22', 500);
const panel = el.querySelector(`#pp-${game.currentPlayer}`);
if (panel) panel.animate([{background:'rgba(76,175,80,0.3)'},{background:'transparent'}], {duration:600});
if (mover === myPlayerIndex) {
audio.play('win', 'reward');
juice.hapticSuccess();
const boardRect = canvas.getBoundingClientRect();
fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[mover]);
juice.screenFlash(COLORS[mover] + '22', 500);
} else {
audio.play('notification');
}
const panel = el.querySelector(`#pp-${mover}`);
if (panel) panel.animate([{ background: 'rgba(76,175,80,0.3)' }, { background: 'transparent' }], { duration: 600 });
}
if (game.gameOver) {
......@@ -807,13 +834,25 @@ function updatePanels(el) {
checkTurboMode(el);
for (let i = 0; i < 4; i++) {
const p = el.querySelector(`#pp-${i}`);
if (p) p.classList.toggle('active', i === game.currentPlayer);
if (p) {
p.classList.toggle('active', i === game.currentPlayer);
if (game.players[i].finished) p.style.opacity = '0.5';
}
}
const diceArea = el.querySelector('#dice-area');
const btn = el.querySelector('#roll-btn');
const diceBox = el.querySelector('#dice-box');
const myTurn = isMyTurn() && !game.players[myPlayerIndex].finished && !game.gameOver;
if (diceArea) {
diceArea.style.opacity = myTurn ? '1' : '0.3';
diceArea.style.pointerEvents = myTurn ? 'auto' : 'none';
}
if (diceBox) diceBox.style.visibility = myTurn || diceAnimating ? 'visible' : 'hidden';
if (btn) {
const canRoll = isMyTurn() && !game.rolled && !diceAnimating && !game.gameOver;
const canRoll = myTurn && !game.rolled && !diceAnimating;
btn.disabled = !canRoll;
btn.style.opacity = canRoll ? '1' : '0.4';
btn.style.visibility = myTurn ? 'visible' : 'hidden';
}
}
......@@ -888,16 +927,31 @@ function handleExit(el) {
function endGame(el) {
game.gameOver = true;
if (matchId) matchLive.session?.destroy?.();
const result = game.winners[0] === myPlayerIndex ? 'win' : 'loss';
if (result === 'win') {
juice.confetti(window.innerWidth/2, window.innerHeight/3, 50);
juice.starBurst(window.innerWidth/2, window.innerHeight/3, 15);
const myRank = game.winners.indexOf(myPlayerIndex);
// Ranks 0,1,2 = 1st,2nd,3rd place (podium). Rank 3 = 4th (loser)
const isLoser = myRank === 3 || myRank === -1;
const result = isLoser ? 'loss' : 'win';
const place = myRank >= 0 ? myRank + 1 : 4;
if (!isLoser) {
juice.confetti(window.innerWidth / 2, window.innerHeight / 3, 50);
juice.starBurst(window.innerWidth / 2, window.innerHeight / 3, 15);
juice.hapticSuccess();
audio.play('win','reward');
audio.play('win', 'reward');
juice.screenFlash('rgba(76,175,80,0.12)', 600);
if (place === 1) {
fireworkBurst(window.innerWidth / 2, window.innerHeight / 3, COLORS[myPlayerIndex]);
}
} else {
audio.play('lose','game');
audio.play('lose', 'game');
juice.hapticError();
juice.screenFlash('rgba(229,57,53,0.12)', 500);
}
setTimeout(() => { scene.exitGameMode(); scene.replace('ludo-result', { result, winners: game.winners }); bus.emit('game:ended', { gameKey: 'ludo', result }); }, 1500);
setTimeout(() => {
scene.exitGameMode();
scene.replace('ludo-result', { result, place, winners: game.winners });
bus.emit('game:ended', { gameKey: 'ludo', result, place });
}, 1500);
}
......@@ -4,14 +4,38 @@ import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
const PLACE_EMOJI = ['🥇', '🥈', '🥉'];
const PLACE_LABEL = ['المركز الأول', 'المركز الثاني', 'المركز الثالث'];
const PLACE_COLOR = ['#FFD700', '#C0C0C0', '#CD7F32'];
export function mountResult(el, params) {
const { result } = params;
const { result, place, resigned } = params;
const isWin = result === 'win';
let content;
if (resigned) {
content = `
<div style="font-size:64px;animation:float 2s ease-in-out infinite;">${emoji('skull', '💀', 64)}</div>
<div style="font-size:24px;font-weight:800;color:var(--loss);">انسحبت من المباراة</div>
`;
} else if (isWin && place >= 1 && place <= 3) {
const idx = place - 1;
content = `
<div style="font-size:72px;animation:float 2s ease-in-out infinite;">${emoji('medal_' + place, PLACE_EMOJI[idx], 72)}</div>
<div style="font-size:28px;font-weight:800;color:${PLACE_COLOR[idx]};">${PLACE_LABEL[idx]}</div>
<div style="font-size:16px;color:#94a3b8;margin-top:4px;">${place === 1 ? 'مبروك! أنت البطل' : 'أحسنت!'}</div>
`;
} else {
content = `
<div style="font-size:64px;animation:float 2s ease-in-out infinite;">${emoji('skull', '💀', 64)}</div>
<div style="font-size:28px;font-weight:800;color:var(--loss);">المركز الرابع</div>
<div style="font-size:16px;color:#94a3b8;margin-top:4px;">حظ أوفر المرة القادمة!</div>
`;
}
el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);padding:var(--s-6);">
<div style="font-size:64px;animation:float 2s ease-in-out infinite;">${isWin ? emoji('trophy', '🏆', 64) : emoji('skull', '💀', 64)}</div>
<div style="font-size:28px;font-weight:800;color:${isWin ? 'var(--win)' : 'var(--loss)'};">${isWin ? t('game.you_win') : t('game.you_lose')}</div>
${content}
<div style="display:flex;gap:var(--s-3);margin-top:var(--s-6);">
<button class="btn btn-primary" id="btn-again">${t('game.rematch')}</button>
<button class="btn btn-secondary" id="btn-back">${t('game.back')}</button>
......
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