Commit 875a302e authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(ludo): piece selection, step-by-step animation, 2-4 player matchmaking, branding admin

PIECE SELECTION:
- Player always taps which piece to move (golden glow highlight on valid pieces)
- Canvas click detection: finds nearest highlighted piece within tap radius
- Even with 1 valid move: brief highlight before auto-execute (confirms to player)

STEP-BY-STEP ANIMATION:
- Pieces hop one square at a time (120ms per hop) instead of teleporting
- Audio cue on each step
- Capture/finish effects play AFTER animation completes
- Confetti on capture, star burst on finish

MATCHMAKING (2-4 players):
- Searches for up to 3 waiting opponents
- 2 humans found → 2 humans + 2 bots
- 3 humans found → 3 humans + 1 bot
- 4 humans found → 4 humans, no bots
- All matched opponents' queue entries updated
- 30s timeout handled client-side (queue scene already has timer)

BRANDING ADMIN:
- New 'Ludo Board Colors' section with 9 color controls:
  Red/Blue/Green/Yellow zones, board bg, path, safe star, piece border, grid lines
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 96ebe555
...@@ -181,6 +181,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) { ...@@ -181,6 +181,37 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['asset'])) {
</div> </div>
</div> </div>
<!-- LUDO BOARD -->
<h2>🎲 Ludo Board Colors</h2>
<div class="section">
<div class="grid">
<?php
$ludoColors = [
['key' => 'ludo_red', 'label' => 'Red player zone', 'default' => '#E53935', 'hint' => 'Red home zone + pieces'],
['key' => 'ludo_blue', 'label' => 'Blue player zone', 'default' => '#1E88E5', 'hint' => 'Blue home zone + pieces'],
['key' => 'ludo_green', 'label' => 'Green player zone', 'default' => '#43A047', 'hint' => 'Green home zone + pieces'],
['key' => 'ludo_yellow', 'label' => 'Yellow player zone', 'default' => '#FDD835', 'hint' => 'Yellow home zone + pieces'],
['key' => 'ludo_board_bg', 'label' => 'Board background', 'default' => '#FAFAFA', 'hint' => 'White board surface'],
['key' => 'ludo_path', 'label' => 'Path cells', 'default' => '#FFFFFF', 'hint' => 'Shared path square color'],
['key' => 'ludo_safe', 'label' => 'Safe square star', 'default' => '#F9A825', 'hint' => 'Star on safe positions'],
['key' => 'ludo_piece_border', 'label' => 'Piece border', 'default' => '#FFFFFF', 'hint' => 'White stroke around pieces'],
['key' => 'ludo_grid_line', 'label' => 'Grid lines', 'default' => '#DDDDDD', 'hint' => 'Grid lines on board'],
];
foreach ($ludoColors as $c):
$val = $theme[$c['key']] ?? $c['default'];
?>
<div class="field">
<label><?= $c['label'] ?></label>
<div class="color-row">
<input type="color" name="theme[<?= $c['key'] ?>]" value="<?= $val ?>">
<input type="text" name="theme[<?= $c['key'] ?>]" value="<?= $val ?>">
</div>
<div class="hint"><?= $c['hint'] ?></div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- ANIMATIONS --> <!-- ANIMATIONS -->
<h2>⚡ الحركة والرسوم المتحركة</h2> <h2>⚡ الحركة والرسوم المتحركة</h2>
<div class="section"> <div class="section">
......
...@@ -29,12 +29,12 @@ function handleLudoQueue(string $userId, array $input): void { ...@@ -29,12 +29,12 @@ function handleLudoQueue(string $userId, array $input): void {
// Clean old waiting entries for this player // Clean old waiting entries for this player
$sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]); $sdb->delete('ludo_queue', ['user_id' => 'eq.' . $userId]);
// Check for waiting opponent // Check for ALL waiting opponents (up to 3 — we need at least 1 to start)
$searchUrl = SUPABASE_REST . '/ludo_queue' $searchUrl = SUPABASE_REST . '/ludo_queue'
. '?user_id=neq.' . $userId . '?user_id=neq.' . $userId
. '&match_id=is.null' . '&match_id=is.null'
. '&select=id,user_id' . '&select=id,user_id'
. '&limit=1'; . '&limit=3';
$ch = curl_init($searchUrl); $ch = curl_init($searchUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
...@@ -49,10 +49,14 @@ function handleLudoQueue(string $userId, array $input): void { ...@@ -49,10 +49,14 @@ function handleLudoQueue(string $userId, array $input): void {
$opponents = json_decode($result, true); $opponents = json_decode($result, true);
if (!empty($opponents) && isset($opponents[0])) { if (!empty($opponents) && isset($opponents[0])) {
$opponent = $opponents[0]; // Found opponents — build player list (up to 4 humans, fill rest with bots)
$humanPlayers = [$opponents[0]['user_id'], $userId];
if (isset($opponents[1])) $humanPlayers[] = $opponents[1]['user_id'];
if (isset($opponents[2])) $humanPlayers[] = $opponents[2]['user_id'];
// Create ludo match: 2 humans (player 0 = opponent who waited, player 1 = this player) + 2 bots $players = $humanPlayers;
$players = [$opponent['user_id'], $userId, 'bot_1', 'bot_2']; $botCount = 4 - count($players);
for ($i = 1; $i <= $botCount; $i++) { $players[] = 'bot_' . $i; }
$matchData = [ $matchData = [
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)), 'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
...@@ -70,15 +74,17 @@ function handleLudoQueue(string $userId, array $input): void { ...@@ -70,15 +74,17 @@ function handleLudoQueue(string $userId, array $input): void {
'moves' => '[]', 'moves' => '[]',
'winners' => '[]', 'winners' => '[]',
'game_state' => json_encode(['turn_count' => 0]), 'game_state' => json_encode(['turn_count' => 0]),
'host_id' => $opponent['user_id'] 'host_id' => $opponents[0]['user_id']
]; ];
$match = $sdb->insert('ludo_matches', $matchData); $match = $sdb->insert('ludo_matches', $matchData);
$matchId = $match[0]['id'] ?? $match['id'] ?? null; $matchId = $match[0]['id'] ?? $match['id'] ?? null;
if ($matchId) { if ($matchId) {
// Mark opponent's queue entry with match_id // Mark ALL opponents' queue entries with match_id
$sdb->update('ludo_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opponent['id']]); foreach ($opponents as $opp) {
$sdb->update('ludo_queue', ['match_id' => $matchId], ['id' => 'eq.' . $opp['id']]);
}
jsonResponse([ jsonResponse([
'match_id' => $matchId, 'match_id' => $matchId,
......
...@@ -174,18 +174,27 @@ function handleRoll(el) { ...@@ -174,18 +174,27 @@ function handleRoll(el) {
setTimeout(() => { diceBox.style.boxShadow = '0 4px 14px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.8)'; }, 600); setTimeout(() => { diceBox.style.boxShadow = '0 4px 14px rgba(0,0,0,0.4),inset 0 1px 0 rgba(255,255,255,0.8)'; }, 600);
} }
validMoves = rules.getValidMoves(game, 0, dice); validMoves = rules.getValidMoves(game, myPlayerIndex, dice);
if (validMoves.length === 0) { if (validMoves.length === 0) {
setTimeout(() => { game.rolled = false; rules.nextTurn(game); btn.disabled = false; btn.style.opacity = '1'; updatePanels(el); drawBoard(); if (!isMyTurn()) handleNonPlayerTurn(el); }, 800); setTimeout(() => { game.rolled = false; rules.nextTurn(game); btn.disabled = false; btn.style.opacity = '1'; updatePanels(el); drawBoard(); if (!isMyTurn()) handleNonPlayerTurn(el); }, 800);
} else if (validMoves.length === 1) {
// Only one option — still highlight it briefly then execute
highlightMovablePieces(validMoves);
setTimeout(() => doMove(el, validMoves[0]), 600);
} else { } else {
const best = validMoves.find(m=>m.type==='capture') || validMoves.find(m=>m.type==='finish') || validMoves.find(m=>m.type==='enter') || validMoves[0]; // Multiple options — let player TAP which piece to move
setTimeout(() => doMove(el, best), 400); highlightMovablePieces(validMoves);
waitForPieceSelection(el, validMoves);
} }
} }
}, 55); }, 55);
} }
function doMove(el, move) { function doMove(el, move) {
// For single-piece auto-moves and bot moves — use animateMove
animateMove(el, move);
return;
// Dead code below kept for reference
rules.applyMove(game, game.currentPlayer, move); rules.applyMove(game, game.currentPlayer, move);
game.rolled = false; game.rolled = false;
if (move.type === 'capture') { audio.play('capture','game'); juice.shake(el,4,200); juice.hapticHeavy(); } if (move.type === 'capture') { audio.play('capture','game'); juice.shake(el,4,200); juice.hapticHeavy(); }
...@@ -285,6 +294,115 @@ function startLudoPolling(el) { ...@@ -285,6 +294,115 @@ function startLudoPolling(el) {
}, 2000); }, 2000);
} }
// ===== PIECE SELECTION + ANIMATION =====
let highlightedPieces = [];
let selectionListener = null;
function highlightMovablePieces(moves) {
highlightedPieces = moves.map(m => m.pieceId);
drawBoard(); // Redraw with highlights
}
function waitForPieceSelection(el, moves) {
// Listen for canvas tap to select a piece
const handler = (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const scaleX = boardSize / rect.width;
const scaleY = boardSize / rect.height;
const cx = x * scaleX;
const cy = y * scaleY;
// Find which movable piece was tapped
for (const move of moves) {
const pIdx = parseInt(move.pieceId.split('-')[0]);
const pieceIdx = parseInt(move.pieceId.split('-')[1]);
const piece = game.players[pIdx].pieces[pieceIdx];
let pos;
if (piece.pos === -1) pos = getHomeBasePosition(pIdx, pieceIdx, cellSize);
else pos = getPiecePosition(piece.pos, pIdx, cellSize);
if (!pos) continue;
const dist = Math.sqrt((cx - pos.x) ** 2 + (cy - pos.y) ** 2);
if (dist < cellSize * 0.6) {
// Selected this piece!
canvas.removeEventListener('click', handler);
selectionListener = null;
highlightedPieces = [];
audio.play('click');
juice.hapticLight();
animateMove(el, move);
return;
}
}
};
selectionListener = handler;
canvas.addEventListener('click', handler);
}
async function animateMove(el, move) {
const pIdx = parseInt(move.pieceId.split('-')[0]);
const pieceIdx = parseInt(move.pieceId.split('-')[1]);
const piece = game.players[pIdx].pieces[pieceIdx];
const fromPos = piece.pos;
const toPos = move.to;
if (move.type === 'enter') {
// Entering from home — just one hop
rules.applyMove(game, game.currentPlayer, move);
drawBoard();
audio.play('move', 'game');
juice.hapticLight();
afterMove(el, move);
return;
}
// 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
for (let i = 1; i <= steps; i++) {
piece.pos = fromPos + i;
drawBoard();
audio.play('move', 'game');
await new Promise(r => setTimeout(r, 120));
}
// Apply capture/finish effects after animation
// Re-apply via rules to handle captures properly
piece.pos = fromPos; // Reset
rules.applyMove(game, game.currentPlayer, move);
drawBoard();
afterMove(el, move);
}
function afterMove(el, move) {
game.rolled = false;
if (move.type === 'capture') { audio.play('capture','game'); juice.shake(el,4,200); juice.hapticHeavy(); juice.confetti(window.innerWidth/2, window.innerHeight/2, 15); }
else if (move.type === 'finish') { audio.play('win','reward'); juice.hapticSuccess(); juice.starBurst(window.innerWidth/2, window.innerHeight/2, 8); }
if (game.gameOver) { endGame(el); return; }
rules.nextTurn(game);
updatePanels(el);
drawBoard();
if (game.mode === 'live') syncLudoState();
const btn = el.querySelector('#roll-btn');
if (isMyTurn()) { btn.disabled = false; btn.style.opacity = '1'; }
else handleNonPlayerTurn(el);
}
// ===== END PIECE SELECTION + ANIMATION =====
function drawBoard() { function drawBoard() {
const cs = cellSize; const cs = cellSize;
clear(ctx, boardSize, boardSize); clear(ctx, boardSize, boardSize);
...@@ -356,7 +474,7 @@ function drawBoard() { ...@@ -356,7 +474,7 @@ function drawBoard() {
const startColors = [[6,1,'#FFCDD2'],[8,13,'#C8E6C9'],[1,8,'#FFF9C4'],[13,6,'#BBDEFB']]; const startColors = [[6,1,'#FFCDD2'],[8,13,'#C8E6C9'],[1,8,'#FFF9C4'],[13,6,'#BBDEFB']];
startColors.forEach(([col,row,color]) => { ctx.fillStyle = color; ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2); }); startColors.forEach(([col,row,color]) => { ctx.fillStyle = color; ctx.fillRect(col*cs+1, row*cs+1, cs-2, cs-2); });
// Pieces — BIGGER with pawn shape // Pieces — with highlight for selectable ones
game.players.forEach((player, pIdx) => { game.players.forEach((player, pIdx) => {
player.pieces.forEach((piece, pieceIdx) => { player.pieces.forEach((piece, pieceIdx) => {
if (piece.finished) return; if (piece.finished) return;
...@@ -364,7 +482,18 @@ function drawBoard() { ...@@ -364,7 +482,18 @@ function drawBoard() {
if (piece.pos === -1) pos = getHomeBasePosition(pIdx, pieceIdx, cs); if (piece.pos === -1) pos = getHomeBasePosition(pIdx, pieceIdx, cs);
else pos = getPiecePosition(piece.pos, pIdx, cs); else pos = getPiecePosition(piece.pos, pIdx, cs);
if (pos) { if (pos) {
const pieceId = `${pIdx}-${pieceIdx}`;
const isHighlighted = highlightedPieces.includes(pieceId);
const r = cs * 0.42; const r = cs * 0.42;
// Glow ring for selectable pieces
if (isHighlighted) {
ctx.beginPath(); ctx.arc(pos.x, pos.y, r + 4, 0, Math.PI*2);
ctx.strokeStyle = '#E4AC38'; ctx.lineWidth = 3; ctx.stroke();
ctx.beginPath(); ctx.arc(pos.x, pos.y, r + 7, 0, Math.PI*2);
ctx.strokeStyle = 'rgba(228,172,56,0.3)'; ctx.lineWidth = 2; ctx.stroke();
}
// Shadow // Shadow
ctx.beginPath(); ctx.arc(pos.x, pos.y + 2, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fill(); ctx.beginPath(); ctx.arc(pos.x, pos.y + 2, r, 0, Math.PI*2); ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fill();
// Body (pawn shape — circle on top of tapered base) // Body (pawn shape — circle on top of tapered base)
......
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