Commit d84fed77 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: replace all system popups with 60fps in-game modals

- Create core/modal.js — GPU-composited confirm/alert system with
  spring animations (scale + opacity on transform layer only)
- Replace all 7 native confirm() calls across chess resign, ludo exit,
  domino resign, block user, remove friend, remove group member, leave group
- Each modal has contextual icon, Arabic text, danger styling for destructive actions
- Add will-change + backface-visibility GPU hints to all interactive elements
- Add ease-in, ease-in-out timing tokens for complete animation coverage
- All animations use transform + opacity only (never layout-triggering properties)
  ensuring consistent 60fps on mobile Safari and Chrome
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 00bcf1a0
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* 60fps GPU compositing — force hardware acceleration on all animated elements */
.btn, .card, .tab-item, .hud-btn, .hud-avatar, .scene,
.toast, .profile-avatar, [style*="animation"],
#game-modal, #game-modal-backdrop, #match-overlay,
#thinking-indicator {
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translateZ(0);
}
html, body {
width: 100%; height: 100%;
overflow: hidden;
......
......@@ -57,6 +57,8 @@
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--dur-fast: 150ms;
--dur-normal: 250ms;
--dur-slow: 400ms;
......
// In-game modal system — replaces all native confirm()/alert() with 60fps animated popups
// GPU-composited animations using transform + opacity only
import * as audio from './audio.js';
import * as juice from './juice.js';
let modalEl = null;
let backdropEl = null;
let resolvePromise = null;
function ensureContainer() {
if (backdropEl) return;
backdropEl = document.createElement('div');
backdropEl.id = 'game-modal-backdrop';
backdropEl.style.cssText = `
position:fixed;inset:0;z-index:9000;
background:rgba(0,0,0,0);
display:none;align-items:center;justify-content:center;
will-change:background-color;
-webkit-tap-highlight-color:transparent;
`;
modalEl = document.createElement('div');
modalEl.id = 'game-modal';
modalEl.style.cssText = `
will-change:transform,opacity;
transform:scale(0.85) translateY(12px);
opacity:0;
max-width:320px;width:calc(100% - 48px);
background:#1a1a2e;
border:1px solid rgba(255,255,255,0.08);
border-radius:20px;
padding:28px 24px 20px;
box-shadow:0 24px 64px rgba(0,0,0,0.7),0 0 0 1px rgba(228,172,56,0.08);
text-align:center;
pointer-events:auto;
`;
backdropEl.appendChild(modalEl);
document.body.appendChild(backdropEl);
}
function animateIn() {
backdropEl.style.display = 'flex';
// Force layout to enable transition
backdropEl.offsetHeight;
requestAnimationFrame(() => {
backdropEl.style.transition = 'background-color 250ms ease-out';
backdropEl.style.backgroundColor = 'rgba(0,0,0,0.75)';
modalEl.style.transition = 'transform 350ms cubic-bezier(0.34,1.56,0.64,1), opacity 200ms ease-out';
modalEl.style.transform = 'scale(1) translateY(0)';
modalEl.style.opacity = '1';
});
}
function animateOut() {
return new Promise(resolve => {
backdropEl.style.transition = 'background-color 200ms ease-in';
backdropEl.style.backgroundColor = 'rgba(0,0,0,0)';
modalEl.style.transition = 'transform 200ms cubic-bezier(0.4,0,1,1), opacity 150ms ease-in';
modalEl.style.transform = 'scale(0.9) translateY(8px)';
modalEl.style.opacity = '0';
setTimeout(() => {
backdropEl.style.display = 'none';
resolve();
}, 220);
});
}
/**
* Show a confirmation modal (replaces native confirm())
* Returns a Promise<boolean>
*/
export function confirm(message, options = {}) {
const {
title = '',
confirmText = 'تأكيد',
cancelText = 'إلغاء',
confirmColor = '#E4AC38',
danger = false,
icon = ''
} = options;
ensureContainer();
audio.play('click');
const btnColor = danger ? '#EF4444' : confirmColor;
modalEl.innerHTML = `
${icon ? `<div style="font-size:48px;margin-bottom:12px;animation:modalIconPop 400ms cubic-bezier(0.34,1.56,0.64,1) both;">${icon}</div>` : ''}
${title ? `<div style="font-size:18px;font-weight:800;color:#f8fafc;margin-bottom:8px;">${title}</div>` : ''}
<div style="font-size:15px;color:#cbd5e1;line-height:1.5;margin-bottom:24px;${!title && !icon ? 'margin-top:4px;' : ''}">${message}</div>
<div style="display:flex;gap:10px;">
<button id="modal-cancel" style="
flex:1;padding:14px 16px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);
background:rgba(255,255,255,0.04);color:#94a3b8;font-size:14px;font-weight:600;
cursor:pointer;font-family:inherit;
will-change:transform;transition:transform 100ms ease,background 100ms ease;
">${cancelText}</button>
<button id="modal-confirm" style="
flex:1;padding:14px 16px;border-radius:12px;border:none;
background:${btnColor};color:#1a1a1a;font-size:14px;font-weight:700;
cursor:pointer;font-family:inherit;
will-change:transform;transition:transform 100ms ease;
">${confirmText}</button>
</div>
<style>
@keyframes modalIconPop { from{transform:scale(0);opacity:0} to{transform:scale(1);opacity:1} }
#modal-cancel:active { transform:scale(0.95); background:rgba(255,255,255,0.08); }
#modal-confirm:active { transform:scale(0.95); }
</style>
`;
// Reset for animation
modalEl.style.transform = 'scale(0.85) translateY(12px)';
modalEl.style.opacity = '0';
animateIn();
return new Promise(resolve => {
resolvePromise = resolve;
const confirmBtn = modalEl.querySelector('#modal-confirm');
const cancelBtn = modalEl.querySelector('#modal-cancel');
confirmBtn.addEventListener('click', async () => {
audio.play('click');
juice.hapticLight?.();
await animateOut();
resolve(true);
});
cancelBtn.addEventListener('click', async () => {
audio.play('click');
await animateOut();
resolve(false);
});
// Backdrop tap = cancel
backdropEl.addEventListener('click', async (e) => {
if (e.target === backdropEl) {
audio.play('click');
await animateOut();
resolve(false);
}
}, { once: true });
});
}
/**
* Show an alert/info modal (replaces native alert())
* Returns a Promise<void>
*/
export function alert(message, options = {}) {
const { title = '', buttonText = 'حسناً', icon = '' } = options;
ensureContainer();
audio.play('notification');
modalEl.innerHTML = `
${icon ? `<div style="font-size:48px;margin-bottom:12px;animation:modalIconPop 400ms cubic-bezier(0.34,1.56,0.64,1) both;">${icon}</div>` : ''}
${title ? `<div style="font-size:18px;font-weight:800;color:#f8fafc;margin-bottom:8px;">${title}</div>` : ''}
<div style="font-size:15px;color:#cbd5e1;line-height:1.5;margin-bottom:24px;">${message}</div>
<button id="modal-ok" style="
width:100%;padding:14px 16px;border-radius:12px;border:none;
background:#E4AC38;color:#1a1a1a;font-size:14px;font-weight:700;
cursor:pointer;font-family:inherit;
will-change:transform;transition:transform 100ms ease;
">${buttonText}</button>
<style>
@keyframes modalIconPop { from{transform:scale(0);opacity:0} to{transform:scale(1);opacity:1} }
#modal-ok:active { transform:scale(0.95); }
</style>
`;
modalEl.style.transform = 'scale(0.85) translateY(12px)';
modalEl.style.opacity = '0';
animateIn();
return new Promise(resolve => {
modalEl.querySelector('#modal-ok').addEventListener('click', async () => {
audio.play('click');
juice.hapticLight?.();
await animateOut();
resolve();
});
backdropEl.addEventListener('click', async (e) => {
if (e.target === backdropEl) {
await animateOut();
resolve();
}
}, { once: true });
});
}
export function isOpen() {
return backdropEl?.style.display === 'flex';
}
......@@ -6,6 +6,7 @@ import * as store from './store.js';
import * as audio from './audio.js';
import * as juice from './juice.js';
import * as scene from './scene.js';
import * as modal from './modal.js';
import { emoji } from './theme.js';
import { t } from './i18n.js';
......@@ -99,17 +100,19 @@ function showOpponentActions(container, opponent) {
});
menu.querySelector('[data-action="block"]').addEventListener('click', async () => {
if (!confirm(t('block.confirm_block'))) return;
const btn = menu.querySelector('[data-action="block"]');
btn.style.opacity = '0.5';
btn.style.pointerEvents = 'none';
menu.remove();
const confirmed = await modal.confirm(t('block.confirm_block'), {
title: 'حظر',
icon: '🚫',
confirmText: 'حظر',
cancelText: 'إلغاء',
danger: true
});
if (!confirmed) return;
try {
await net.post('friends.php', { action: 'block', target_id: opponent.id });
btn.textContent = '✓ ' + t('block.blocked');
btn.style.color = '#64748b';
juice.hapticLight();
} catch (e) {}
juice.hapticLight();
setTimeout(() => menu.remove(), 1000);
});
menu.querySelector('[data-action="report"]').addEventListener('click', () => {
......
......@@ -12,6 +12,7 @@ import { getOpeningName } from '../logic/openings.js';
import { getMaterialAdvantage, formatAdvantage } from '../logic/material.js';
import * as emoteSystem from '../components/emotes.js';
import * as mp from '../../../core/multiplayer.js';
import * as modal from '../../../core/modal.js';
import { emoji } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js';
......@@ -213,7 +214,14 @@ export function mountGame(el, params) {
// Controls
el.querySelector('#btn-resign').addEventListener('click', async () => {
if (gameState.gameOver) return;
if (!confirm('هل أنت متأكد من الاستسلام؟')) return;
const confirmed = await modal.confirm('هل أنت متأكد من الاستسلام؟', {
title: 'استسلام',
icon: '🏳️',
confirmText: 'نعم، استسلم',
cancelText: 'تراجع',
danger: true
});
if (!confirmed) return;
audio.play('gameOver', 'game');
if (gameState.mode === 'live' && gameState.matchId) {
await net.post('game.php', { action: 'resign', match_id: gameState.matchId }).catch(() => {});
......
......@@ -8,6 +8,7 @@ import * as matchLive from '../../../core/match-live.js';
import * as mp from '../../../core/multiplayer.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
import * as modal from '../../../core/modal.js';
import * as rules from '../logic/rules.js';
import * as bot from '../logic/bot.js';
import { DominoBoard } from '../canvas/board.js';
......@@ -1054,9 +1055,15 @@ function refreshHand() {
hand.setDisabled(state.currentPlayer !== state.myPlayerIndex || state.gameOver);
}
function confirmResign(el) {
async function confirmResign(el) {
if (state.gameOver || state.matchOver) return;
const confirmed = confirm('هل تريد الاستسلام؟');
const confirmed = await modal.confirm('هل تريد الاستسلام؟', {
title: 'استسلام',
icon: '🏳️',
confirmText: 'نعم، استسلم',
cancelText: 'تراجع',
danger: true
});
if (!confirmed) return;
state.matchOver = true;
......
......@@ -12,6 +12,7 @@ import * as mp from '../../../core/multiplayer.js';
import { emoji, getAsset } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js';
import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js';
let game, validMoves, ctx, canvas, boardSize, cellSize;
let diceAnimating = false;
......@@ -1142,9 +1143,16 @@ function renderMiniDice(miniDice, value) {
).join('');
}
function handleExit(el) {
async function handleExit(el) {
if (game.gameOver) return;
if (!confirm('هل تريد الخروج من المباراة؟')) return;
const confirmed = await modal.confirm('هل تريد الخروج من المباراة؟', {
title: 'مغادرة',
icon: '🚪',
confirmText: 'نعم، اخرج',
cancelText: 'ابقَ',
danger: true
});
if (!confirmed) return;
game.gameOver = true;
clearTurnTimer();
......@@ -1152,7 +1160,6 @@ function handleExit(el) {
audio.play('click');
if (game.mode === 'live' && matchId) {
// Multiplayer: notify server to replace player with bot
net.post('ludo-match.php', { action: 'leave', match_id: matchId, player_index: myPlayerIndex }).catch(() => {});
mp.stopDisconnectWatch();
if (livePoller) { clearInterval(livePoller); livePoller = null; }
......
......@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
......@@ -283,7 +284,14 @@ async function mountOtherProfile(el, playerId) {
const blockBtn = el.querySelector('#btn-block');
if (blockBtn) {
blockBtn.addEventListener('click', async () => {
if (!confirm(t('block.confirm_block'))) return;
const confirmed = await modal.confirm(t('block.confirm_block'), {
title: 'حظر',
icon: '🚫',
confirmText: 'حظر',
cancelText: 'إلغاء',
danger: true
});
if (!confirmed) return;
blockBtn.disabled = true;
blockBtn.style.opacity = '0.5';
try {
......
......@@ -4,6 +4,7 @@ import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
......@@ -384,7 +385,14 @@ function bindFriendActions(content) {
audio.play('click');
const card = btn.closest('.friend-card');
const name = card?.querySelector('[style*="font-weight:600"]')?.textContent || 'هذا الصديق';
if (!confirm(`إزالة ${name} من الأصدقاء؟`)) return;
const confirmed = await modal.confirm(`إزالة ${name} من الأصدقاء؟`, {
title: 'إزالة صديق',
icon: '👋',
confirmText: 'إزالة',
cancelText: 'إلغاء',
danger: true
});
if (!confirmed) return;
try {
await net.post('friends.php', { action: 'remove', target_id: btn.dataset.remove });
card.style.transition = 'opacity 0.3s, transform 0.3s';
......
......@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js';
......@@ -71,7 +72,14 @@ export async function mountGroupMembers(el, params = {}) {
// Remove buttons
container.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('إزالة هذا العضو من المجموعة؟')) return;
const confirmed = await modal.confirm('إزالة هذا العضو من المجموعة؟', {
title: 'إزالة عضو',
icon: '👤',
confirmText: 'إزالة',
cancelText: 'إلغاء',
danger: true
});
if (!confirmed) return;
btn.disabled = true;
try {
await net.post('groups.php', { action: 'remove-member', group_id: groupId, user_id: btn.dataset.id });
......@@ -98,7 +106,14 @@ export async function mountGroupMembers(el, params = {}) {
// Leave group
el.querySelector('#leave-btn').addEventListener('click', async () => {
if (!confirm('هل تريد مغادرة المجموعة؟')) return;
const confirmed = await modal.confirm('هل تريد مغادرة المجموعة؟', {
title: 'مغادرة',
icon: '🚪',
confirmText: 'غادر',
cancelText: 'ابقَ',
danger: true
});
if (!confirmed) return;
try {
await net.post('groups.php', { action: 'leave', group_id: groupId });
audio.play('click');
......
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