Commit 8f6dd147 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: multiplayer UI overlays — disconnect, reconnect, abandon, toasts

Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 412ee8f6
......@@ -50,6 +50,107 @@ html, body {
#hud.hidden { transform: translateY(-100%); }
.hud-profile {
display: flex;
align-items: center;
gap: var(--s-2);
cursor: pointer;
position: relative;
}
.hud-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--bg-elevated);
border: 2px solid var(--gold);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: transform var(--dur-fast) var(--ease-spring);
}
.hud-profile:active .hud-avatar { transform: scale(0.9); }
.hud-level-badge {
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
background: var(--gold);
color: #1a1a1a;
font-size: 9px;
font-weight: 800;
font-family: var(--font-lat);
min-width: 16px;
height: 14px;
line-height: 14px;
text-align: center;
border-radius: var(--r-full);
padding: 0 3px;
}
.hud-stats {
display: flex;
align-items: center;
gap: var(--s-4);
}
.hud-stat {
display: flex;
align-items: center;
gap: var(--s-1);
font-family: var(--font-lat);
font-size: 14px;
font-weight: 700;
}
.hud-stat .icon { display: flex; align-items: center; }
.hud-stat.hud-coins { color: var(--gold); }
.hud-stat.hud-gems { color: var(--purple); }
.hud-actions {
display: flex;
align-items: center;
gap: var(--s-2);
}
.hud-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
transition: transform var(--dur-fast) var(--ease-spring);
}
.hud-btn:active { transform: scale(0.88); }
.hud-badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 16px;
height: 16px;
line-height: 16px;
background: var(--orange);
color: white;
font-size: 9px;
font-weight: 700;
font-family: var(--font-lat);
border-radius: var(--r-full);
text-align: center;
padding: 0 4px;
display: none;
}
.hud-badge:not(:empty) { display: block; }
#overlay {
position: fixed;
inset: 0;
......@@ -150,6 +251,45 @@ html, body {
@keyframes sceneIn { 0% { opacity: 0; transform: scale(0.88); } 60% { opacity: 1; transform: scale(1.02); } 100% { transform: scale(1); } }
@keyframes sceneOut { to { opacity: 0; transform: scale(1.05); } }
/* Profile Avatar */
.profile-avatar-wrap {
position: relative;
width: 88px;
height: 88px;
margin: 0 auto;
cursor: pointer;
}
.profile-avatar {
width: 88px;
height: 88px;
border-radius: 50%;
background: var(--bg-elevated);
border: 3px solid var(--gold);
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
overflow: hidden;
transition: transform var(--dur-fast) var(--ease-spring);
}
.profile-avatar-wrap:active .profile-avatar { transform: scale(0.93); }
.profile-avatar-edit {
position: absolute;
bottom: 2px;
right: 2px;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--gold);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--bg-card);
}
/* Cards */
.card {
background: var(--bg-card);
......
// Match Live — glue between match-session (logic) and match-ui (visuals)
// Games import this ONE module to get full multiplayer edge case handling
import * as session from './match-session.js';
import * as ui from './match-ui.js';
import * as mp from './multiplayer.js';
export function start(matchId, gameType, options = {}) {
const { onMove, onGameEnd } = options;
let lastMoveCount = 0;
let connectionLost = false;
const sess = session.create(matchId, gameType, {
onOpponentMove: (data) => {
session.markOpponentActive();
ui.hideOpponentThinking();
if (connectionLost) {
connectionLost = false;
ui.showConnectionRestored();
}
// Detect new move
const moveCount = data.move_count || data.current_turn || 0;
if (moveCount > lastMoveCount) {
lastMoveCount = moveCount;
onMove?.(data);
}
// Detect game ended externally
if (data.status === 'completed') {
onGameEnd?.(data);
}
// Check for opponent ping (they're still connected)
if (data.game_state) {
try {
const gs = typeof data.game_state === 'string' ? JSON.parse(data.game_state) : data.game_state;
if (gs.ping) session.markOpponentActive();
} catch (e) {}
}
},
onOpponentDisconnect: () => {
ui.showOpponentDisconnect();
},
onOpponentReconnect: () => {
ui.showOpponentReconnect();
},
onOpponentAbandon: () => {
// Auto-win handled by UI (shows trophy + navigates back)
},
onConnectionLost: () => {
connectionLost = true;
ui.showConnectionLost();
},
onConnectionRestored: () => {
if (connectionLost) {
connectionLost = false;
ui.showConnectionRestored();
}
}
});
return {
session: sess,
markOpponentActive: () => session.markOpponentActive(),
showThinking: () => ui.showOpponentThinking(),
hideThinking: () => ui.hideOpponentThinking(),
showReconnecting: () => ui.showReconnecting(),
toast: (msg, color) => ui.toast(msg, color),
cleanup: () => { session.destroy(); ui.cleanup(); }
};
}
export function recover() {
const match = session.getRecoverableMatch();
if (match) {
ui.showReconnecting();
return match;
}
return null;
}
export { session, ui };
// Match UI — overlays and popups for all multiplayer edge cases
// Shows: disconnect warning, reconnecting spinner, opponent left, auto-win countdown, connection lost
import * as audio from './audio.js';
import * as juice from './juice.js';
import * as bus from './bus.js';
import * as scene from './scene.js';
let overlayEl = null;
function getOverlay() {
if (!overlayEl) {
overlayEl = document.createElement('div');
overlayEl.id = 'match-overlay';
overlayEl.style.cssText = 'position:fixed;inset:0;z-index:500;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.85);backdrop-filter:blur(4px);transition:opacity 0.3s;';
document.body.appendChild(overlayEl);
}
return overlayEl;
}
function show(content) {
const ov = getOverlay();
ov.innerHTML = content;
ov.style.display = 'flex';
ov.style.opacity = '0';
requestAnimationFrame(() => { ov.style.opacity = '1'; });
}
function hide() {
const ov = getOverlay();
ov.style.opacity = '0';
setTimeout(() => { ov.style.display = 'none'; ov.innerHTML = ''; }, 300);
}
// ========== OPPONENT DISCONNECTED ==========
let abandonTimer = null;
let abandonSeconds = 60;
export function showOpponentDisconnect() {
audio.play('notification');
abandonSeconds = 60;
show(`
<div style="text-align:center;padding:32px;max-width:300px;">
<div style="width:64px;height:64px;margin:0 auto 16px;border-radius:50%;background:#1e1e3a;display:flex;align-items:center;justify-content:center;">
<div style="width:16px;height:16px;border:3px solid #FBBF24;border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite;"></div>
</div>
<div style="font-size:18px;font-weight:700;color:#f8fafc;margin-bottom:8px;">الخصم انقطع اتصاله</div>
<div style="font-size:13px;color:#94a3b8;margin-bottom:16px;">في انتظار عودته...</div>
<div id="abandon-countdown" style="font-size:32px;font-weight:800;color:#FBBF24;font-family:Inter,monospace;margin-bottom:16px;">60</div>
<div style="font-size:11px;color:#64748b;">ستفوز تلقائياً إذا لم يعد خلال الوقت المحدد</div>
<button id="claim-win-btn" style="margin-top:16px;padding:10px 24px;background:#E4AC38;border:none;border-radius:8px;color:#1a1a1a;font-weight:700;font-size:13px;cursor:pointer;opacity:0.5;pointer-events:none;">فوز مبكر</button>
</div>
<style>@keyframes spin { to { transform: rotate(360deg); } }</style>
`);
// Countdown timer
abandonTimer = setInterval(() => {
abandonSeconds--;
const countEl = document.getElementById('abandon-countdown');
if (countEl) countEl.textContent = String(abandonSeconds);
// Enable claim button after 30s
if (abandonSeconds <= 30) {
const btn = document.getElementById('claim-win-btn');
if (btn) { btn.style.opacity = '1'; btn.style.pointerEvents = 'auto'; }
}
if (abandonSeconds <= 0) {
clearInterval(abandonTimer);
showAutoWin();
}
}, 1000);
// Claim win button
setTimeout(() => {
document.getElementById('claim-win-btn')?.addEventListener('click', () => {
clearInterval(abandonTimer);
showAutoWin();
});
}, 100);
}
// ========== OPPONENT RECONNECTED ==========
export function showOpponentReconnect() {
clearInterval(abandonTimer);
hide();
// Brief toast
showToast('✅ الخصم عاد — استمروا!', '#34D399');
audio.play('notification');
}
// ========== AUTO WIN (opponent abandoned) ==========
function showAutoWin() {
clearInterval(abandonTimer);
audio.play('win', 'reward');
juice.hapticSuccess();
juice.confetti(window.innerWidth / 2, window.innerHeight / 3, 30);
show(`
<div style="text-align:center;padding:32px;max-width:300px;">
<div style="font-size:56px;margin-bottom:12px;animation:float 2s ease-in-out infinite;">🏆</div>
<div style="font-size:22px;font-weight:800;color:#34D399;margin-bottom:8px;">فزت!</div>
<div style="font-size:14px;color:#94a3b8;margin-bottom:20px;">الخصم غادر المباراة</div>
<button id="abandon-back-btn" style="padding:12px 32px;background:linear-gradient(135deg,#E4AC38,#FFCC66);border:none;border-radius:10px;color:#1a1a1a;font-weight:700;font-size:15px;cursor:pointer;">العودة</button>
</div>
`);
setTimeout(() => {
document.getElementById('abandon-back-btn')?.addEventListener('click', () => {
hide();
bus.emit('navigate', { world: 'play', scene: 'play-table' });
});
}, 100);
// Emit win event for rating/coins
bus.emit('game:ended', { result: 'win', reason: 'abandon' });
}
// ========== CONNECTION LOST (your own network) ==========
export function showConnectionLost() {
show(`
<div style="text-align:center;padding:32px;max-width:280px;">
<div style="width:48px;height:48px;margin:0 auto 16px;border-radius:50%;background:#EF4444;display:flex;align-items:center;justify-content:center;">
<span style="font-size:24px;">⚠️</span>
</div>
<div style="font-size:16px;font-weight:700;color:#f8fafc;margin-bottom:6px;">انقطع الاتصال</div>
<div style="font-size:13px;color:#94a3b8;">جاري إعادة الاتصال...</div>
<div style="margin-top:16px;">
<div style="width:120px;height:4px;background:#1e1e3a;border-radius:2px;margin:0 auto;overflow:hidden;">
<div style="width:30%;height:100%;background:#FBBF24;border-radius:2px;animation:loading 1.5s ease-in-out infinite;"></div>
</div>
</div>
</div>
<style>@keyframes loading { 0% { transform:translateX(-100%); } 100% { transform:translateX(400%); } }</style>
`);
}
// ========== CONNECTION RESTORED ==========
export function showConnectionRestored() {
hide();
showToast('✅ تم استعادة الاتصال', '#34D399');
}
// ========== RECONNECTING (tab refresh recovery) ==========
export function showReconnecting() {
show(`
<div style="text-align:center;padding:32px;">
<div style="width:48px;height:48px;margin:0 auto 16px;border:3px solid #E4AC38;border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite;"></div>
<div style="font-size:16px;font-weight:700;color:#f8fafc;">جاري استعادة المباراة...</div>
</div>
<style>@keyframes spin { to { transform: rotate(360deg); } }</style>
`);
}
// ========== YOUR TURN REMINDER (if idle too long) ==========
export function showTurnReminder() {
showToast('⏱️ دورك! العب قبل انتهاء الوقت', '#FBBF24');
juice.hapticMedium();
}
// ========== OPPONENT THINKING ==========
export function showOpponentThinking() {
const existing = document.getElementById('thinking-indicator');
if (existing) return;
const el = document.createElement('div');
el.id = 'thinking-indicator';
el.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);background:rgba(30,30,58,0.95);padding:6px 14px;border-radius:20px;font-size:12px;color:#94a3b8;z-index:400;display:flex;align-items:center;gap:6px;border:1px solid rgba(255,255,255,0.06);';
el.innerHTML = `<div style="width:8px;height:8px;border:2px solid #94a3b8;border-top-color:transparent;border-radius:50%;animation:spin 0.8s linear infinite;"></div> الخصم يفكر...`;
document.body.appendChild(el);
}
export function hideOpponentThinking() {
document.getElementById('thinking-indicator')?.remove();
}
// ========== TOAST (brief notification) ==========
function showToast(message, color = '#f8fafc') {
const toast = document.createElement('div');
toast.style.cssText = `position:fixed;top:70px;left:50%;transform:translateX(-50%);background:#1a1a2e;border:1px solid ${color}44;padding:10px 20px;border-radius:12px;font-size:13px;font-weight:600;color:${color};z-index:600;box-shadow:0 4px 20px rgba(0,0,0,0.5);animation:toastIn 0.4s cubic-bezier(0.34,1.56,0.64,1);`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toastOut 0.2s ease-in forwards';
setTimeout(() => toast.remove(), 200);
}, 3000);
}
// ========== CLEANUP ==========
export function cleanup() {
hide();
clearInterval(abandonTimer);
hideOpponentThinking();
}
// ========== EXPORT TOAST FOR EXTERNAL USE ==========
export { showToast as toast };
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