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) { ...@@ -70,7 +70,7 @@ export function gridToPixel(col, row, cellSize) {
} }
// Get pixel position for a piece given its local position and player index // 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) { export function getPiecePosition(localPos, playerIdx, cellSize) {
if (localPos === -1) return null; if (localPos === -1) return null;
...@@ -80,8 +80,9 @@ export function getPiecePosition(localPos, playerIdx, cellSize) { ...@@ -80,8 +80,9 @@ export function getPiecePosition(localPos, playerIdx, cellSize) {
const [col, row] = HOME_COLUMNS[playerIdx][homeIdx]; const [col, row] = HOME_COLUMNS[playerIdx][homeIdx];
return gridToPixel(col, row, cellSize); return gridToPixel(col, row, cellSize);
} }
// Finished — center of board // Fallback — last home column square (should not normally be reached)
return gridToPixel(7, 7, cellSize); const [col, row] = HOME_COLUMNS[playerIdx][5];
return gridToPixel(col, row, cellSize);
} }
const globalPos = (localPos + START_SQUARES[playerIdx]) % 52; const globalPos = (localPos + START_SQUARES[playerIdx]) % 52;
......
const SHARED_PATH_LENGTH = 52; const SHARED_PATH_LENGTH = 52;
const HOME_ENTRY_POS = 50; const HOME_ENTRY_POS = 50;
const HOME_STRETCH = 6; const HOME_STRETCH = 5;
const FINISH_POS = HOME_ENTRY_POS + 1 + HOME_STRETCH; // 57 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 // Must match board-map.js START_SQUARES exactly
const START_POSITIONS = [0, 13, 26, 39]; const START_POSITIONS = [0, 13, 26, 39];
const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47]; const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
...@@ -188,7 +188,7 @@ export function applyMove(game, playerIdx, move) { ...@@ -188,7 +188,7 @@ export function applyMove(game, playerIdx, move) {
if (move.type === 'enter') { if (move.type === 'enter') {
piece.pos = 0; piece.pos = 0;
const globalPos = START_POSITIONS[playerIdx]; const globalPos = START_POSITIONS[playerIdx];
captureAt(game, playerIdx, globalPos); move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true; game.extraTurn = true;
} else if (move.type === 'finish') { } else if (move.type === 'finish') {
piece.pos = move.to; piece.pos = move.to;
...@@ -197,14 +197,17 @@ export function applyMove(game, playerIdx, move) { ...@@ -197,14 +197,17 @@ export function applyMove(game, playerIdx, move) {
if (player.pieces.every(p => p.finished)) { if (player.pieces.every(p => p.finished)) {
player.finished = true; player.finished = true;
game.winners.push(playerIdx); 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; game.gameOver = true;
} }
} }
} else if (move.type === 'capture') { } else if (move.type === 'capture') {
piece.pos = move.to; piece.pos = move.to;
const globalPos = (move.to + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH; const globalPos = (move.to + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
captureAt(game, playerIdx, globalPos); move._capturedPlayers = captureAt(game, playerIdx, globalPos);
game.extraTurn = true; game.extraTurn = true;
} else if (move.type === 'home') { } else if (move.type === 'home') {
piece.pos = move.to; piece.pos = move.to;
...@@ -254,7 +257,8 @@ function checkCapture(game, playerIdx, globalPos) { ...@@ -254,7 +257,8 @@ function checkCapture(game, playerIdx, globalPos) {
} }
function captureAt(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++) { for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue; if (i === playerIdx) continue;
...@@ -264,9 +268,11 @@ function captureAt(game, playerIdx, globalPos) { ...@@ -264,9 +268,11 @@ function captureAt(game, playerIdx, globalPos) {
const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH; const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
if (theirGlobal === globalPos) { if (theirGlobal === globalPos) {
piece.pos = -1; piece.pos = -1;
if (!captured.includes(i)) captured.push(i);
} }
} }
} }
return captured;
} }
export function getBotMove(game, playerIdx, dice) { export function getBotMove(game, playerIdx, dice) {
......
...@@ -213,7 +213,7 @@ function isMyTurn() { ...@@ -213,7 +213,7 @@ function isMyTurn() {
} }
async function handleRoll(el) { 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; diceAnimating = true;
updatePanels(el); updatePanels(el);
...@@ -268,7 +268,7 @@ function handleNonPlayerTurn(el) { ...@@ -268,7 +268,7 @@ function handleNonPlayerTurn(el) {
} }
async function botLoop(el) { async function botLoop(el) {
if (game.gameOver || isMyTurn()) return; if (game.gameOver || isMyTurn() || game.players[game.currentPlayer].finished) return;
// === BOT PERSONALITY PROFILES === // === BOT PERSONALITY PROFILES ===
const personalities = { const personalities = {
...@@ -343,10 +343,18 @@ async function botLoop(el) { ...@@ -343,10 +343,18 @@ async function botLoop(el) {
rules.applyMove(game, game.currentPlayer, move); rules.applyMove(game, game.currentPlayer, move);
if (move.type === 'capture') { if (move.type === 'capture') {
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'); audio.play('capture', 'game');
juice.shake(el, 3, 150); juice.shake(el, 3, 150);
}
} else if (move.type === 'finish') { } else if (move.type === 'finish') {
audio.play('win', 'reward'); audio.play('notification');
const boardRect = canvas.getBoundingClientRect(); const boardRect = canvas.getBoundingClientRect();
fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[game.currentPlayer]); fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[game.currentPlayer]);
} }
...@@ -616,22 +624,41 @@ function fireworkBurst(x, y, color) { ...@@ -616,22 +624,41 @@ function fireworkBurst(x, y, color) {
function afterMove(el, move) { function afterMove(el, move) {
game.rolled = false; game.rolled = false;
const mover = game.currentPlayer;
if (move.type === 'capture') { if (move.type === 'capture') {
audio.play('capture','game'); if (mover === myPlayerIndex) {
juice.shake(el, 6, 300); // I captured someone — celebrate!
juice.hapticHeavy(); audio.play('capture', 'reward');
juice.confetti(window.innerWidth/2, window.innerHeight/2, 25); juice.hapticSuccess();
juice.screenFlash('rgba(229,57,53,0.15)', 400); 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') { } else if (move.type === 'finish') {
audio.play('win','reward'); if (mover === myPlayerIndex) {
audio.play('win', 'reward');
juice.hapticSuccess(); juice.hapticSuccess();
const boardRect = canvas.getBoundingClientRect(); const boardRect = canvas.getBoundingClientRect();
const centerX = boardRect.left + boardRect.width / 2; fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[mover]);
const centerY = boardRect.top + boardRect.height / 2; juice.screenFlash(COLORS[mover] + '22', 500);
fireworkBurst(centerX, centerY, COLORS[game.currentPlayer]); } else {
juice.screenFlash(COLORS[game.currentPlayer] + '22', 500); audio.play('notification');
const panel = el.querySelector(`#pp-${game.currentPlayer}`); }
if (panel) panel.animate([{background:'rgba(76,175,80,0.3)'},{background:'transparent'}], {duration:600}); const panel = el.querySelector(`#pp-${mover}`);
if (panel) panel.animate([{ background: 'rgba(76,175,80,0.3)' }, { background: 'transparent' }], { duration: 600 });
} }
if (game.gameOver) { if (game.gameOver) {
...@@ -807,13 +834,25 @@ function updatePanels(el) { ...@@ -807,13 +834,25 @@ function updatePanels(el) {
checkTurboMode(el); checkTurboMode(el);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const p = el.querySelector(`#pp-${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 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) { if (btn) {
const canRoll = isMyTurn() && !game.rolled && !diceAnimating && !game.gameOver; const canRoll = myTurn && !game.rolled && !diceAnimating;
btn.disabled = !canRoll; btn.disabled = !canRoll;
btn.style.opacity = canRoll ? '1' : '0.4'; btn.style.opacity = canRoll ? '1' : '0.4';
btn.style.visibility = myTurn ? 'visible' : 'hidden';
} }
} }
...@@ -888,16 +927,31 @@ function handleExit(el) { ...@@ -888,16 +927,31 @@ function handleExit(el) {
function endGame(el) { function endGame(el) {
game.gameOver = true; game.gameOver = true;
if (matchId) matchLive.session?.destroy?.(); if (matchId) matchLive.session?.destroy?.();
const result = game.winners[0] === myPlayerIndex ? 'win' : 'loss';
if (result === 'win') { const myRank = game.winners.indexOf(myPlayerIndex);
juice.confetti(window.innerWidth/2, window.innerHeight/3, 50); // Ranks 0,1,2 = 1st,2nd,3rd place (podium). Rank 3 = 4th (loser)
juice.starBurst(window.innerWidth/2, window.innerHeight/3, 15); 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(); juice.hapticSuccess();
audio.play('win','reward'); audio.play('win', 'reward');
juice.screenFlash('rgba(76,175,80,0.12)', 600); juice.screenFlash('rgba(76,175,80,0.12)', 600);
if (place === 1) {
fireworkBurst(window.innerWidth / 2, window.innerHeight / 3, COLORS[myPlayerIndex]);
}
} else { } else {
audio.play('lose','game'); audio.play('lose', 'game');
juice.hapticError(); 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'; ...@@ -4,14 +4,38 @@ import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
const PLACE_EMOJI = ['🥇', '🥈', '🥉'];
const PLACE_LABEL = ['المركز الأول', 'المركز الثاني', 'المركز الثالث'];
const PLACE_COLOR = ['#FFD700', '#C0C0C0', '#CD7F32'];
export function mountResult(el, params) { export function mountResult(el, params) {
const { result } = params; const { result, place, resigned } = params;
const isWin = result === 'win'; 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 = ` 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="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> ${content}
<div style="font-size:28px;font-weight:800;color:${isWin ? 'var(--win)' : 'var(--loss)'};">${isWin ? t('game.you_win') : t('game.you_lose')}</div>
<div style="display:flex;gap:var(--s-3);margin-top:var(--s-6);"> <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-primary" id="btn-again">${t('game.rematch')}</button>
<button class="btn btn-secondary" id="btn-back">${t('game.back')}</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