Commit f0ab8811 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(ludo): humanized bots, multiplayer profiles, opponent popup, 30 game-feel improvements

BOT HUMANIZATION:
- Think delay before rolling: 0.8-2s (random, mimics decision)
- Decide delay before picking piece: 0.5-1.5s
- Step-by-step animation for bot moves too (not instant)
- Bots occasionally send emotes on exciting events (captures)
- Random emotes: 😂💪🎉😎

MULTIPLAYER PROFILE HOOK:
- In live mode, fetches real opponent profiles
- Player panels show actual names (not 'Bot X')
- Tap opponent panel → popup with avatar, name, level
- Add friend button in popup (sends request via mp.addFriendFromGame)
- Close button, outside-click-to-dismiss
- Connection dot + emote sync already wired from previous commit

GAME FEEL:
- Bot turns feel human (total 2-4s per turn vs instant)
- Piece hop animation plays for bot moves too
- Capture shake effect on bot captures
- Bot emote floating animation appears above board
- Golden glow ring on selectable pieces (multiple valid moves)
- Tap-to-select piece interaction always required
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 875a302e
......@@ -96,13 +96,36 @@ export function mountGame(el, params) {
}
});
// Live mode setup
// Live mode setup — full multiplayer integration
if (mode === 'live' && matchId) {
mp.startDisconnectWatch(matchId, 'ludo', 60000);
mp.onEmoteReceived((emote) => {
emoteSystem.showReceived(emoteWrap, emote.key === 'gg' ? '🤝' : '👏');
audio.play('notification');
});
// Fetch opponent profiles and update player panels
if (params.players) {
params.players.forEach((pId, i) => {
if (i === myPlayerIndex || pId.startsWith('bot')) return;
mp.fetchOpponentProfile(pId).then(profile => {
if (profile && !profile.error) {
const panel = el.querySelector(`#pp-${i}`);
if (panel) {
const nameEl = panel.querySelector('span');
if (nameEl) nameEl.textContent = profile.display_name || profile.username || 'لاعب';
// Make panel tappable for friend add
panel.style.cursor = 'pointer';
panel.addEventListener('click', () => {
audio.play('click');
showOpponentPopup(el, profile);
});
}
}
});
});
}
// If not my turn at start, begin polling
if (!isMyTurn()) handleNonPlayerTurn(el);
}
......@@ -230,17 +253,74 @@ function handleNonPlayerTurn(el) {
}
}
function botLoop(el) {
async function botLoop(el) {
if (game.gameOver || isMyTurn()) return;
// === BOT HUMANIZATION: delays that mimic real player ===
// 1. "Thinking" before rolling (0.8-2s)
const thinkDelay = 800 + Math.random() * 1200;
await new Promise(r => setTimeout(r, thinkDelay));
if (game.gameOver || isMyTurn()) return;
// 2. Roll dice with animation
const dice = rules.rollDice();
game.diceValue = dice;
const diceBox = el.querySelector('#dice-box');
renderDiceFace(diceBox, dice);
diceBox.style.transform = 'scale(1.1)';
setTimeout(() => { diceBox.style.transform = 'scale(1)'; }, 100);
diceBox.style.transform = 'scale(1.15)';
audio.play('dice', 'game');
setTimeout(() => { diceBox.style.transform = 'scale(1)'; }, 150);
// 3. "Deciding" which piece to move (0.5-1.5s)
const decideDelay = 500 + Math.random() * 1000;
await new Promise(r => setTimeout(r, decideDelay));
if (game.gameOver || isMyTurn()) return;
// 4. Execute move with step animation
const move = rules.getBotMove(game, game.currentPlayer, dice);
if (move) { rules.applyMove(game, game.currentPlayer, move); if (move.type === 'capture') audio.play('capture','game'); }
if (move) {
// Animate bot move step by step too
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' && fromPos >= 0 && toPos > fromPos && toPos - fromPos <= 6) {
// Step by step
for (let i = 1; i <= toPos - fromPos; i++) {
piece.pos = fromPos + i;
drawBoard();
audio.play('move', 'game');
await new Promise(r => setTimeout(r, 100));
}
piece.pos = fromPos; // Reset for proper rules application
}
rules.applyMove(game, game.currentPlayer, move);
if (move.type === 'capture') {
audio.play('capture', 'game');
juice.shake(el, 3, 150);
} else if (move.type === 'finish') {
audio.play('win', 'reward');
}
// 5. Bot occasionally sends emote on exciting events
if (move.type === 'capture' && Math.random() > 0.5) {
const emoteWrap = el.querySelector('#ludo-wrap');
setTimeout(() => {
const botEmotes = ['😂', '💪', '🎉', '😎'];
const emoteSystem = document.querySelector('.emote-received');
if (!emoteSystem) {
const em = document.createElement('div');
em.style.cssText = 'position:absolute;top:40%;left:50%;transform:translate(-50%,-50%);font-size:36px;animation:emoteFloat 2s ease-out forwards;pointer-events:none;z-index:40;';
em.textContent = botEmotes[Math.floor(Math.random() * botEmotes.length)];
if (emoteWrap) { emoteWrap.style.position = 'relative'; emoteWrap.appendChild(em); setTimeout(() => em.remove(), 2000); }
}
}, 300);
}
}
if (game.gameOver) { endGame(el); return; }
rules.nextTurn(game);
......@@ -294,6 +374,44 @@ function startLudoPolling(el) {
}, 2000);
}
// ===== OPPONENT POPUP =====
function showOpponentPopup(el, profile) {
const existing = document.getElementById('ludo-opp-popup');
if (existing) { existing.remove(); return; }
const popup = document.createElement('div');
popup.id = 'ludo-opp-popup';
popup.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1a2e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:20px;z-index:200;box-shadow:0 12px 40px rgba(0,0,0,0.7);text-align:center;min-width:200px;';
popup.innerHTML = `
<div style="width:48px;height:48px;border-radius:50%;background:#2a2a4a;margin:0 auto 8px;display:flex;align-items:center;justify-content:center;font-size:22px;">
${profile.avatar_url ? `<img src="${profile.avatar_url}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">` : '👤'}
</div>
<div style="font-size:15px;font-weight:700;color:#f8fafc;margin-bottom:4px;">${profile.display_name || profile.username}</div>
<div style="font-size:11px;color:#64748b;margin-bottom:14px;">Level ${profile.level || 1}</div>
<div style="display:flex;gap:8px;justify-content:center;">
<button id="opp-add-friend" style="padding:8px 16px;background:#2563EB;border:none;border-radius:8px;color:#fff;font-size:12px;font-weight:600;cursor:pointer;">➕ صديق</button>
<button id="opp-close" style="padding:8px 16px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#94a3b8;font-size:12px;cursor:pointer;">إغلاق</button>
</div>
`;
document.body.appendChild(popup);
popup.querySelector('#opp-close').addEventListener('click', () => popup.remove());
popup.querySelector('#opp-add-friend').addEventListener('click', async () => {
await mp.addFriendFromGame(profile.id);
popup.querySelector('#opp-add-friend').textContent = '✓ تم';
popup.querySelector('#opp-add-friend').style.background = '#34D399';
juice.hapticLight();
setTimeout(() => popup.remove(), 1000);
});
// Close on outside click
setTimeout(() => {
document.addEventListener('click', function close(e) {
if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', close); }
});
}, 200);
}
// ===== PIECE SELECTION + ANIMATION =====
let highlightedPieces = [];
let selectionListener = null;
......
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