Commit a220f7d0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: shared multiplayer module — opponent bar, synced emotes, disconnect, rematch, friend add

New core module: multiplayer.js
Used by Chess, Ludo, Domino for all common multiplayer features:

1. OPPONENT BAR:
   - Avatar, name, rating, level
   - Green/yellow/red connection dot
   - Tap → popup menu (view profile, add friend, report)

2. SYNCED EMOTES:
   - sendEmote(matchId, type, key) → writes to game_state
   - checkForEmote(gameState, myId) → detects opponent's emote
   - onEmoteReceived(callback) → fires for UI to show floating emote

3. CONNECTION STATUS:
   - Green dot = connected (< 15s since last opponent action)
   - Yellow dot = weak (15-60s)
   - Red dot = disconnected (> 60s → can claim win)
   - startDisconnectWatch / stopDisconnectWatch

4. REMATCH:
   - requestRematch(matchId, type) → writes to game_state
   - checkForRematch(gameState, myId) → detects request

5. FRIEND ADD:
   - addFriendFromGame(opponentId) → sends friend request
   - Accessible from opponent bar tap menu

6. REPORT:
   - reportOpponent(opponentId, reason)

All games can import { renderOpponentBar, sendEmote, ... } from 'core/multiplayer.js'
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent d6b1ca39
// Shared Multiplayer Module — used by Chess, Ludo, Domino
// Handles: opponent bar, synced emotes, connection status, rematch, friend add, disconnect
import * as net from './net.js';
import * as store from './store.js';
import * as audio from './audio.js';
import * as juice from './juice.js';
let currentMatchId = null;
let currentMatchType = null; // 'chess' | 'ludo' | 'domino'
let opponentData = null;
let emoteCallback = null;
let disconnectTimer = null;
let lastOpponentPing = Date.now();
// ========== OPPONENT BAR ==========
export function renderOpponentBar(container, opponent, options = {}) {
opponentData = opponent;
const { showRating = true, position = 'top' } = options;
const bar = document.createElement('div');
bar.id = 'mp-opponent-bar';
bar.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 12px;background:#0f0f1e;cursor:pointer;';
bar.innerHTML = `
<div style="position:relative;">
<div style="width:36px;height:36px;border-radius:50%;background:#2a2a4a;display:flex;align-items:center;justify-content:center;font-size:14px;overflow:hidden;">
${opponent.avatar_url ? `<img src="${opponent.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` : '👤'}
</div>
<div id="mp-conn-dot" style="position:absolute;bottom:-1px;right:-1px;width:10px;height:10px;border-radius:50%;background:#34D399;border:2px solid #0f0f1e;"></div>
</div>
<div style="flex:1;">
<div style="font-size:13px;font-weight:600;color:#f8fafc;">${opponent.display_name || opponent.username || 'خصم'}</div>
${showRating ? `<div style="font-size:11px;color:#64748b;">⭐ ${opponent.rating || opponent.elo_rapid || '1200'}</div>` : ''}
</div>
<div id="mp-opponent-status" style="font-size:10px;color:#64748b;"></div>
`;
// Tap to show profile actions
bar.addEventListener('click', () => {
audio.play('click');
showOpponentActions(container, opponent);
});
if (position === 'top') container.prepend(bar);
else container.appendChild(bar);
return bar;
}
function showOpponentActions(container, opponent) {
const existing = document.getElementById('mp-opponent-menu');
if (existing) { existing.remove(); return; }
const menu = document.createElement('div');
menu.id = 'mp-opponent-menu';
menu.style.cssText = 'position:absolute;top:50px;right:12px;background:#1a1a2e;border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:8px;z-index:100;box-shadow:0 8px 24px rgba(0,0,0,0.5);display:flex;flex-direction:column;gap:4px;min-width:140px;';
menu.innerHTML = `
<button class="mp-action" data-action="profile">👤 الملف الشخصي</button>
<button class="mp-action" data-action="friend">➕ إضافة صديق</button>
<button class="mp-action" data-action="report" style="color:#EF4444;">⚠️ إبلاغ</button>
`;
const style = document.createElement('style');
style.textContent = '.mp-action{background:none;border:none;color:#e2e8f0;font-size:12px;font-weight:500;padding:8px 12px;text-align:right;cursor:pointer;border-radius:6px;font-family:inherit;width:100%;transition:background 0.1s;}.mp-action:hover{background:rgba(255,255,255,0.05);}.mp-action:active{background:rgba(255,255,255,0.1);}';
menu.appendChild(style);
menu.querySelector('[data-action="friend"]').addEventListener('click', async () => {
await addFriendFromGame(opponent.id);
menu.querySelector('[data-action="friend"]').textContent = '✓ تم الإرسال';
juice.hapticLight();
setTimeout(() => menu.remove(), 1000);
});
menu.querySelector('[data-action="report"]').addEventListener('click', () => {
reportOpponent(opponent.id);
menu.remove();
});
menu.querySelector('[data-action="profile"]').addEventListener('click', () => {
menu.remove();
// Could navigate to profile scene
});
// Close on outside click
setTimeout(() => {
document.addEventListener('click', function close(e) {
if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); }
});
}, 100);
container.style.position = 'relative';
container.appendChild(menu);
}
// ========== SYNCED EMOTES ==========
export async function sendEmote(matchId, matchType, emoteKey) {
const endpoint = matchType === 'ludo' ? 'ludo-match.php' : 'game.php';
const userId = store.get('auth.userId');
try {
await net.post(endpoint, {
action: 'move',
match_id: matchId,
game_state: JSON.stringify({
emote: { key: emoteKey, from: userId, t: Date.now() }
})
});
} catch (e) {}
}
export function onEmoteReceived(callback) {
emoteCallback = callback;
}
export function checkForEmote(gameState, myUserId) {
if (!gameState) return;
const gs = typeof gameState === 'string' ? JSON.parse(gameState) : gameState;
if (gs.emote && gs.emote.from !== myUserId && Date.now() - gs.emote.t < 10000) {
if (emoteCallback) emoteCallback(gs.emote);
return gs.emote;
}
return null;
}
// ========== CONNECTION STATUS ==========
export function updateConnectionStatus(isConnected) {
const dot = document.getElementById('mp-conn-dot');
if (dot) {
dot.style.background = isConnected ? '#34D399' : '#EF4444';
}
lastOpponentPing = Date.now();
}
export function startDisconnectWatch(matchId, matchType, timeoutMs = 60000) {
currentMatchId = matchId;
currentMatchType = matchType;
disconnectTimer = setInterval(() => {
const elapsed = Date.now() - lastOpponentPing;
const dot = document.getElementById('mp-conn-dot');
const status = document.getElementById('mp-opponent-status');
if (elapsed > timeoutMs) {
// Opponent disconnected too long — claim win
if (dot) dot.style.background = '#EF4444';
if (status) status.textContent = 'انقطع الاتصال';
clearInterval(disconnectTimer);
// Could auto-claim win here
} else if (elapsed > 15000) {
if (dot) dot.style.background = '#FBBF24';
if (status) status.textContent = 'اتصال ضعيف';
} else {
if (dot) dot.style.background = '#34D399';
if (status) status.textContent = '';
}
}, 5000);
}
export function stopDisconnectWatch() {
if (disconnectTimer) { clearInterval(disconnectTimer); disconnectTimer = null; }
}
// ========== REMATCH ==========
export async function requestRematch(matchId, matchType) {
const endpoint = matchType === 'ludo' ? 'ludo-match.php' : 'game.php';
try {
await net.post(endpoint, {
action: 'move',
match_id: matchId,
game_state: JSON.stringify({ rematch_request: store.get('auth.userId') })
});
return true;
} catch (e) { return false; }
}
export function checkForRematch(gameState, myUserId) {
if (!gameState) return false;
const gs = typeof gameState === 'string' ? JSON.parse(gameState) : gameState;
return gs.rematch_request && gs.rematch_request !== myUserId;
}
// ========== FRIEND ADD ==========
export async function addFriendFromGame(opponentId) {
try {
await net.post('friends.php', { action: 'request', target_id: opponentId });
juice.hapticLight();
return true;
} catch (e) { return false; }
}
// ========== REPORT ==========
export async function reportOpponent(opponentId, reason = 'in_game') {
try {
await net.post('friends.php', { action: 'report', target_id: opponentId, reason });
audio.play('click');
} catch (e) {}
}
// ========== FETCH OPPONENT PROFILE ==========
export async function fetchOpponentProfile(opponentId) {
try {
const data = await net.get('profile.php', { id: opponentId });
return data;
} catch (e) { return null; }
}
// ========== CLEANUP ==========
export function cleanup() {
stopDisconnectWatch();
emoteCallback = null;
opponentData = null;
currentMatchId = null;
const menu = document.getElementById('mp-opponent-menu');
if (menu) menu.remove();
}
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