Commit c55124cc authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(ludo): capture-boost dice, skip-corner path, and firework finish

- Add "lucky roll" mechanic: when an opponent pawn is exposed directly
  ahead (1-6 squares), boost probability of rolling the exact capture
  value by ~+1.0 weight, plus +0.6 for any die value that enables capture
- Change pawn path: pieces enter home column from position 50 (the square
  before the corner), treating the corner as dead space for their own
  color — matches standard Ludo turn geometry
- Add multi-wave firework particle burst (player-colored) when a pawn
  lands exactly on the Home finish position
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 984d5189
...@@ -45,9 +45,9 @@ export const HOME_COLUMNS = [ ...@@ -45,9 +45,9 @@ export const HOME_COLUMNS = [
[[13,7],[12,7],[11,7],[10,7],[9,7],[8,7]], // Blue: right center going LEFT [[13,7],[12,7],[11,7],[10,7],[9,7],[8,7]], // Blue: right center going LEFT
]; ];
// The GLOBAL square at local position 51 (just before entering home column) // The GLOBAL square at local position 50 (last shared square before home column turn)
// Red: [6,14], Green: [0,6], Yellow: [8,0], Blue: [14,8] // Pawns skip the corner square and turn directly into home
export const HOME_ENTRY = [51, 12, 25, 38]; export const HOME_ENTRY = [50, 11, 24, 37];
// Safe squares — each player's start + midpoints // Safe squares — each player's start + midpoints
export const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47]; export const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
...@@ -70,15 +70,17 @@ export function gridToPixel(col, row, cellSize) { ...@@ -70,15 +70,17 @@ 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)
export function getPiecePosition(localPos, playerIdx, cellSize) { export function getPiecePosition(localPos, playerIdx, cellSize) {
if (localPos === -1) return null; if (localPos === -1) return null;
if (localPos >= 52) { if (localPos >= 51) {
const homeIdx = localPos - 52; const homeIdx = localPos - 51;
if (homeIdx < 6) { if (homeIdx < 6) {
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
return gridToPixel(7, 7, cellSize); return gridToPixel(7, 7, cellSize);
} }
......
const BOARD_SIZE = 52; const SHARED_PATH_LENGTH = 52;
const HOME_ENTRY_POS = 50;
const HOME_STRETCH = 6; const HOME_STRETCH = 6;
const FINISH_POS = HOME_ENTRY_POS + 1 + HOME_STRETCH; // 57
// 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];
...@@ -47,25 +49,52 @@ function getDiceContext(game, playerIdx) { ...@@ -47,25 +49,52 @@ function getDiceContext(game, playerIdx) {
const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length; const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length;
const progress = player.pieces.reduce((sum, p) => { const progress = player.pieces.reduce((sum, p) => {
if (p.finished) return sum + 58; if (p.finished) return sum + FINISH_POS;
if (p.pos === -1) return sum; if (p.pos === -1) return sum;
return sum + p.pos; return sum + p.pos;
}, 0) / (58 * 4) * 100; }, 0) / (FINISH_POS * 4) * 100;
const othersProgress = game.players const othersProgress = game.players
.filter((_, i) => i !== playerIdx && !game.players[i].finished) .filter((_, i) => i !== playerIdx && !game.players[i].finished)
.map(op => op.pieces.reduce((s, p) => s + (p.finished ? 58 : p.pos === -1 ? 0 : p.pos), 0) / (58 * 4) * 100); .map(op => op.pieces.reduce((s, p) => s + (p.finished ? FINISH_POS : p.pos === -1 ? 0 : p.pos), 0) / (FINISH_POS * 4) * 100);
const avgOthersProgress = othersProgress.length ? othersProgress.reduce((a, b) => a + b, 0) / othersProgress.length : 0; const avgOthersProgress = othersProgress.length ? othersProgress.reduce((a, b) => a + b, 0) / othersProgress.length : 0;
let killValues = 0; let killValues = 0;
const capturePerDie = [false, false, false, false, false, false];
for (let d = 1; d <= 6; d++) { for (let d = 1; d <= 6; d++) {
const moves = getValidMoves(game, playerIdx, d); const moves = getValidMoves(game, playerIdx, d);
if (moves.some(m => m.type === 'capture')) killValues++; if (moves.some(m => m.type === 'capture')) {
killValues++;
capturePerDie[d - 1] = true;
}
} }
const nearCapture = detectNearCapture(game, playerIdx);
const elapsed = game.startTime ? (Date.now() - game.startTime) / 60000 : 0; const elapsed = game.startTime ? (Date.now() - game.startTime) / 60000 : 0;
return { piecesHome, progress, avgOthersProgress, killValues, elapsed }; return { piecesHome, progress, avgOthersProgress, killValues, capturePerDie, nearCapture, elapsed };
}
function detectNearCapture(game, playerIdx) {
const player = game.players[playerIdx];
let bestDist = Infinity;
for (const piece of player.pieces) {
if (piece.pos === -1 || piece.finished || piece.pos >= HOME_ENTRY_POS) continue;
const myGlobal = (piece.pos + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
for (let opp = 0; opp < game.numPlayers; opp++) {
if (opp === playerIdx) continue;
for (const op of game.players[opp].pieces) {
if (op.pos === -1 || op.finished || op.pos >= HOME_ENTRY_POS) continue;
const theirGlobal = (op.pos + START_POSITIONS[opp]) % SHARED_PATH_LENGTH;
if (SAFE_SQUARES.includes(theirGlobal)) continue;
const stepsNeeded = (theirGlobal - myGlobal + SHARED_PATH_LENGTH) % SHARED_PATH_LENGTH;
if (stepsNeeded >= 1 && stepsNeeded <= 6 && stepsNeeded < bestDist) {
bestDist = stepsNeeded;
}
}
}
}
return bestDist <= 6 ? bestDist : 0;
} }
function computeDiceWeights(ctx) { function computeDiceWeights(ctx) {
...@@ -94,6 +123,15 @@ function computeDiceWeights(ctx) { ...@@ -94,6 +123,15 @@ function computeDiceWeights(ctx) {
w[5] += 0.3; w[5] += 0.3;
} }
// "Lucky roll" — boost the exact die value needed to capture an exposed opponent
if (ctx.nearCapture > 0) {
const idx = ctx.nearCapture - 1;
w[idx] += 1.0;
}
for (let i = 0; i < 6; i++) {
if (ctx.capturePerDie[i]) w[i] += 0.6;
}
return w; return w;
} }
...@@ -115,28 +153,25 @@ export function getValidMoves(game, playerIdx, dice) { ...@@ -115,28 +153,25 @@ export function getValidMoves(game, playerIdx, dice) {
if (piece.finished) continue; if (piece.finished) continue;
if (piece.pos === -1) { if (piece.pos === -1) {
// Can only enter board on a 6
if (dice === 6) { if (dice === 6) {
moves.push({ pieceId: piece.id, from: -1, to: 0, type: 'enter' }); moves.push({ pieceId: piece.id, from: -1, to: 0, type: 'enter' });
} }
} else { } else {
// Pawn turns into home column directly from position 50 (skipping the corner)
const newPos = piece.pos + dice; const newPos = piece.pos + dice;
// Exact finish: must land exactly on position 52+6=58 if (newPos === FINISH_POS) {
if (newPos === BOARD_SIZE + HOME_STRETCH) {
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'finish' }); moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'finish' });
} else if (newPos < BOARD_SIZE + HOME_STRETCH) { } else if (newPos < FINISH_POS) {
if (newPos >= BOARD_SIZE) { if (newPos > HOME_ENTRY_POS) {
// Entering home column — always safe, can't be captured
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'home' }); moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'home' });
} else { } else {
// Moving on shared path const globalPos = (newPos + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
const globalPos = (newPos + START_POSITIONS[playerIdx]) % BOARD_SIZE;
const captured = checkCapture(game, playerIdx, globalPos); const captured = checkCapture(game, playerIdx, globalPos);
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: captured ? 'capture' : 'move' }); moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: captured ? 'capture' : 'move' });
} }
} }
// If newPos > 58, move is invalid (overshoot) — not added // newPos > FINISH_POS = overshoot, invalid
} }
} }
...@@ -152,15 +187,13 @@ export function applyMove(game, playerIdx, move) { ...@@ -152,15 +187,13 @@ export function applyMove(game, playerIdx, move) {
if (move.type === 'enter') { if (move.type === 'enter') {
piece.pos = 0; piece.pos = 0;
// Check for capture at start position
const globalPos = START_POSITIONS[playerIdx]; const globalPos = START_POSITIONS[playerIdx];
captureAt(game, playerIdx, globalPos); captureAt(game, playerIdx, globalPos);
game.extraTurn = true; // 6 gives extra turn game.extraTurn = true;
} else if (move.type === 'finish') { } else if (move.type === 'finish') {
piece.pos = move.to; piece.pos = move.to;
piece.finished = true; piece.finished = true;
game.extraTurn = true; // Finishing gives extra turn game.extraTurn = true;
// Check if player won
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);
...@@ -170,20 +203,18 @@ export function applyMove(game, playerIdx, move) { ...@@ -170,20 +203,18 @@ export function applyMove(game, playerIdx, move) {
} }
} 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]) % BOARD_SIZE; const globalPos = (move.to + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
captureAt(game, playerIdx, globalPos); captureAt(game, playerIdx, globalPos);
game.extraTurn = true; // Capture gives extra turn game.extraTurn = true;
} else if (move.type === 'home') { } else if (move.type === 'home') {
piece.pos = move.to; piece.pos = move.to;
} else { } else {
piece.pos = move.to; piece.pos = move.to;
} }
// Rolling 6 always gives extra turn
if (game.diceValue === 6) { if (game.diceValue === 6) {
game.extraTurn = true; game.extraTurn = true;
player.consecutiveSixes = (player.consecutiveSixes || 0) + 1; player.consecutiveSixes = (player.consecutiveSixes || 0) + 1;
// Three consecutive 6s = lose turn (piece that last moved goes back home)
if (player.consecutiveSixes >= 3) { if (player.consecutiveSixes >= 3) {
piece.pos = -1; piece.pos = -1;
piece.finished = false; piece.finished = false;
...@@ -208,16 +239,14 @@ export function nextTurn(game) { ...@@ -208,16 +239,14 @@ export function nextTurn(game) {
} }
function checkCapture(game, playerIdx, globalPos) { function checkCapture(game, playerIdx, globalPos) {
// Can't capture on safe squares
if (SAFE_SQUARES.includes(globalPos)) return false; if (SAFE_SQUARES.includes(globalPos)) return false;
for (let i = 0; i < game.numPlayers; i++) { for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue; if (i === playerIdx) continue;
for (const piece of game.players[i].pieces) { for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue; if (piece.pos === -1 || piece.finished) continue;
// Only check pieces on the shared path (pos < 52) if (piece.pos > HOME_ENTRY_POS) continue;
if (piece.pos >= BOARD_SIZE) continue; const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % BOARD_SIZE;
if (theirGlobal === globalPos) return true; if (theirGlobal === globalPos) return true;
} }
} }
...@@ -231,10 +260,10 @@ function captureAt(game, playerIdx, globalPos) { ...@@ -231,10 +260,10 @@ function captureAt(game, playerIdx, globalPos) {
if (i === playerIdx) continue; if (i === playerIdx) continue;
for (const piece of game.players[i].pieces) { for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue; if (piece.pos === -1 || piece.finished) continue;
if (piece.pos >= BOARD_SIZE) continue; // Can't capture in home column if (piece.pos > HOME_ENTRY_POS) continue;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % BOARD_SIZE; const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
if (theirGlobal === globalPos) { if (theirGlobal === globalPos) {
piece.pos = -1; // Send back to home piece.pos = -1;
} }
} }
} }
......
...@@ -347,6 +347,8 @@ async function botLoop(el) { ...@@ -347,6 +347,8 @@ async function botLoop(el) {
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('win', 'reward');
const boardRect = canvas.getBoundingClientRect();
fireworkBurst(boardRect.left + boardRect.width / 2, boardRect.top + boardRect.height / 2, COLORS[game.currentPlayer]);
} }
if (move.type === 'capture' && Math.random() > 0.5) { if (move.type === 'capture' && Math.random() > 0.5) {
...@@ -595,6 +597,23 @@ async function animateMove(el, move) { ...@@ -595,6 +597,23 @@ async function animateMove(el, move) {
afterMove(el, move); afterMove(el, move);
} }
function fireworkBurst(x, y, color) {
const lighten = (hex, pct) => {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, ((num >> 16) & 0xFF) + pct);
const g = Math.min(255, ((num >> 8) & 0xFF) + pct);
const b = Math.min(255, (num & 0xFF) + pct);
return `rgb(${r},${g},${b})`;
};
const colors = [color, lighten(color, 80), lighten(color, 140), '#fff'];
// Wave 1 — fast tight burst
juice.burst(x, y, { count: 16, colors, size: 7, spread: 90, duration: 700 });
// Wave 2 — delayed wider burst
setTimeout(() => juice.burst(x, y, { count: 20, colors, size: 9, spread: 160, duration: 1000, type: 'star' }), 200);
// Wave 3 — sparkle trail
setTimeout(() => juice.burst(x, y, { count: 10, colors: [color, '#fff'], size: 5, spread: 200, duration: 1200 }), 450);
}
function afterMove(el, move) { function afterMove(el, move) {
game.rolled = false; game.rolled = false;
if (move.type === 'capture') { if (move.type === 'capture') {
...@@ -606,9 +625,11 @@ function afterMove(el, move) { ...@@ -606,9 +625,11 @@ function afterMove(el, move) {
} else if (move.type === 'finish') { } else if (move.type === 'finish') {
audio.play('win','reward'); audio.play('win','reward');
juice.hapticSuccess(); juice.hapticSuccess();
juice.starBurst(window.innerWidth/2, window.innerHeight/2, 12);
const boardRect = canvas.getBoundingClientRect(); const boardRect = canvas.getBoundingClientRect();
juice.starBurst(boardSize/2 + boardRect.left, boardSize/2 + boardRect.top, 10); 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}`); const panel = el.querySelector(`#pp-${game.currentPlayer}`);
if (panel) panel.animate([{background:'rgba(76,175,80,0.3)'},{background:'transparent'}], {duration:600}); if (panel) panel.animate([{background:'rgba(76,175,80,0.3)'},{background:'transparent'}], {duration:600});
} }
......
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