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 = [
[[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)
// Red: [6,14], Green: [0,6], Yellow: [8,0], Blue: [14,8]
export const HOME_ENTRY = [51, 12, 25, 38];
// The GLOBAL square at local position 50 (last shared square before home column turn)
// Pawns skip the corner square and turn directly into home
export const HOME_ENTRY = [50, 11, 24, 37];
// Safe squares — each player's start + midpoints
export const SAFE_SQUARES = [0, 8, 13, 21, 26, 34, 39, 47];
......@@ -70,15 +70,17 @@ 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)
export function getPiecePosition(localPos, playerIdx, cellSize) {
if (localPos === -1) return null;
if (localPos >= 52) {
const homeIdx = localPos - 52;
if (localPos >= 51) {
const homeIdx = localPos - 51;
if (homeIdx < 6) {
const [col, row] = HOME_COLUMNS[playerIdx][homeIdx];
return gridToPixel(col, row, cellSize);
}
// Finished — center of board
return gridToPixel(7, 7, cellSize);
}
......
const BOARD_SIZE = 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
// 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];
......@@ -47,25 +49,52 @@ function getDiceContext(game, playerIdx) {
const piecesHome = player.pieces.filter(p => p.pos === -1 && !p.finished).length;
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;
return sum + p.pos;
}, 0) / (58 * 4) * 100;
}, 0) / (FINISH_POS * 4) * 100;
const othersProgress = game.players
.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;
let killValues = 0;
const capturePerDie = [false, false, false, false, false, false];
for (let d = 1; d <= 6; 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;
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) {
......@@ -94,6 +123,15 @@ function computeDiceWeights(ctx) {
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;
}
......@@ -115,28 +153,25 @@ export function getValidMoves(game, playerIdx, dice) {
if (piece.finished) continue;
if (piece.pos === -1) {
// Can only enter board on a 6
if (dice === 6) {
moves.push({ pieceId: piece.id, from: -1, to: 0, type: 'enter' });
}
} else {
// Pawn turns into home column directly from position 50 (skipping the corner)
const newPos = piece.pos + dice;
// Exact finish: must land exactly on position 52+6=58
if (newPos === BOARD_SIZE + HOME_STRETCH) {
if (newPos === FINISH_POS) {
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'finish' });
} else if (newPos < BOARD_SIZE + HOME_STRETCH) {
if (newPos >= BOARD_SIZE) {
// Entering home column — always safe, can't be captured
} else if (newPos < FINISH_POS) {
if (newPos > HOME_ENTRY_POS) {
moves.push({ pieceId: piece.id, from: piece.pos, to: newPos, type: 'home' });
} else {
// Moving on shared path
const globalPos = (newPos + START_POSITIONS[playerIdx]) % BOARD_SIZE;
const globalPos = (newPos + START_POSITIONS[playerIdx]) % SHARED_PATH_LENGTH;
const captured = checkCapture(game, playerIdx, globalPos);
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) {
if (move.type === 'enter') {
piece.pos = 0;
// Check for capture at start position
const globalPos = START_POSITIONS[playerIdx];
captureAt(game, playerIdx, globalPos);
game.extraTurn = true; // 6 gives extra turn
game.extraTurn = true;
} else if (move.type === 'finish') {
piece.pos = move.to;
piece.finished = true;
game.extraTurn = true; // Finishing gives extra turn
// Check if player won
game.extraTurn = true;
if (player.pieces.every(p => p.finished)) {
player.finished = true;
game.winners.push(playerIdx);
......@@ -170,20 +203,18 @@ export function applyMove(game, playerIdx, move) {
}
} else if (move.type === 'capture') {
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);
game.extraTurn = true; // Capture gives extra turn
game.extraTurn = true;
} else if (move.type === 'home') {
piece.pos = move.to;
} else {
piece.pos = move.to;
}
// Rolling 6 always gives extra turn
if (game.diceValue === 6) {
game.extraTurn = true;
player.consecutiveSixes = (player.consecutiveSixes || 0) + 1;
// Three consecutive 6s = lose turn (piece that last moved goes back home)
if (player.consecutiveSixes >= 3) {
piece.pos = -1;
piece.finished = false;
......@@ -208,16 +239,14 @@ export function nextTurn(game) {
}
function checkCapture(game, playerIdx, globalPos) {
// Can't capture on safe squares
if (SAFE_SQUARES.includes(globalPos)) return false;
for (let i = 0; i < game.numPlayers; i++) {
if (i === playerIdx) continue;
for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue;
// Only check pieces on the shared path (pos < 52)
if (piece.pos >= BOARD_SIZE) continue;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % BOARD_SIZE;
if (piece.pos > HOME_ENTRY_POS) continue;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
if (theirGlobal === globalPos) return true;
}
}
......@@ -231,10 +260,10 @@ function captureAt(game, playerIdx, globalPos) {
if (i === playerIdx) continue;
for (const piece of game.players[i].pieces) {
if (piece.pos === -1 || piece.finished) continue;
if (piece.pos >= BOARD_SIZE) continue; // Can't capture in home column
const theirGlobal = (piece.pos + START_POSITIONS[i]) % BOARD_SIZE;
if (piece.pos > HOME_ENTRY_POS) continue;
const theirGlobal = (piece.pos + START_POSITIONS[i]) % SHARED_PATH_LENGTH;
if (theirGlobal === globalPos) {
piece.pos = -1; // Send back to home
piece.pos = -1;
}
}
}
......
......@@ -347,6 +347,8 @@ async function botLoop(el) {
juice.shake(el, 3, 150);
} else if (move.type === 'finish') {
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) {
......@@ -595,6 +597,23 @@ async function animateMove(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) {
game.rolled = false;
if (move.type === 'capture') {
......@@ -606,9 +625,11 @@ function afterMove(el, move) {
} else if (move.type === 'finish') {
audio.play('win','reward');
juice.hapticSuccess();
juice.starBurst(window.innerWidth/2, window.innerHeight/2, 12);
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}`);
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