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; } *, *::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 { html, body {
width: 100%; height: 100%; width: 100%; height: 100%;
overflow: hidden; overflow: hidden;
......
...@@ -57,6 +57,8 @@ ...@@ -57,6 +57,8 @@
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 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-fast: 150ms;
--dur-normal: 250ms; --dur-normal: 250ms;
--dur-slow: 400ms; --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'; ...@@ -6,6 +6,7 @@ import * as store from './store.js';
import * as audio from './audio.js'; import * as audio from './audio.js';
import * as juice from './juice.js'; import * as juice from './juice.js';
import * as scene from './scene.js'; import * as scene from './scene.js';
import * as modal from './modal.js';
import { emoji } from './theme.js'; import { emoji } from './theme.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
...@@ -99,17 +100,19 @@ function showOpponentActions(container, opponent) { ...@@ -99,17 +100,19 @@ function showOpponentActions(container, opponent) {
}); });
menu.querySelector('[data-action="block"]').addEventListener('click', async () => { menu.querySelector('[data-action="block"]').addEventListener('click', async () => {
if (!confirm(t('block.confirm_block'))) return; menu.remove();
const btn = menu.querySelector('[data-action="block"]'); const confirmed = await modal.confirm(t('block.confirm_block'), {
btn.style.opacity = '0.5'; title: 'حظر',
btn.style.pointerEvents = 'none'; icon: '🚫',
confirmText: 'حظر',
cancelText: 'إلغاء',
danger: true
});
if (!confirmed) return;
try { try {
await net.post('friends.php', { action: 'block', target_id: opponent.id }); await net.post('friends.php', { action: 'block', target_id: opponent.id });
btn.textContent = '✓ ' + t('block.blocked'); juice.hapticLight();
btn.style.color = '#64748b';
} catch (e) {} } catch (e) {}
juice.hapticLight();
setTimeout(() => menu.remove(), 1000);
}); });
menu.querySelector('[data-action="report"]').addEventListener('click', () => { menu.querySelector('[data-action="report"]').addEventListener('click', () => {
......
...@@ -12,6 +12,7 @@ import { getOpeningName } from '../logic/openings.js'; ...@@ -12,6 +12,7 @@ import { getOpeningName } from '../logic/openings.js';
import { getMaterialAdvantage, formatAdvantage } from '../logic/material.js'; import { getMaterialAdvantage, formatAdvantage } from '../logic/material.js';
import * as emoteSystem from '../components/emotes.js'; import * as emoteSystem from '../components/emotes.js';
import * as mp from '../../../core/multiplayer.js'; import * as mp from '../../../core/multiplayer.js';
import * as modal from '../../../core/modal.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js'; import * as matchLive from '../../../core/match-live.js';
...@@ -213,7 +214,14 @@ export function mountGame(el, params) { ...@@ -213,7 +214,14 @@ export function mountGame(el, params) {
// Controls // Controls
el.querySelector('#btn-resign').addEventListener('click', async () => { el.querySelector('#btn-resign').addEventListener('click', async () => {
if (gameState.gameOver) return; 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'); audio.play('gameOver', 'game');
if (gameState.mode === 'live' && gameState.matchId) { if (gameState.mode === 'live' && gameState.matchId) {
await net.post('game.php', { action: 'resign', match_id: gameState.matchId }).catch(() => {}); await net.post('game.php', { action: 'resign', match_id: gameState.matchId }).catch(() => {});
......
...@@ -8,6 +8,7 @@ import * as matchLive from '../../../core/match-live.js'; ...@@ -8,6 +8,7 @@ import * as matchLive from '../../../core/match-live.js';
import * as mp from '../../../core/multiplayer.js'; import * as mp from '../../../core/multiplayer.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
import * as modal from '../../../core/modal.js';
import * as rules from '../logic/rules.js'; import * as rules from '../logic/rules.js';
import * as bot from '../logic/bot.js'; import * as bot from '../logic/bot.js';
import { DominoBoard } from '../canvas/board.js'; import { DominoBoard } from '../canvas/board.js';
...@@ -1054,9 +1055,15 @@ function refreshHand() { ...@@ -1054,9 +1055,15 @@ function refreshHand() {
hand.setDisabled(state.currentPlayer !== state.myPlayerIndex || state.gameOver); hand.setDisabled(state.currentPlayer !== state.myPlayerIndex || state.gameOver);
} }
function confirmResign(el) { async function confirmResign(el) {
if (state.gameOver || state.matchOver) return; if (state.gameOver || state.matchOver) return;
const confirmed = confirm('هل تريد الاستسلام؟'); const confirmed = await modal.confirm('هل تريد الاستسلام؟', {
title: 'استسلام',
icon: '🏳️',
confirmText: 'نعم، استسلم',
cancelText: 'تراجع',
danger: true
});
if (!confirmed) return; if (!confirmed) return;
state.matchOver = true; state.matchOver = true;
......
...@@ -12,6 +12,7 @@ import * as mp from '../../../core/multiplayer.js'; ...@@ -12,6 +12,7 @@ import * as mp from '../../../core/multiplayer.js';
import { emoji, getAsset } from '../../../core/theme.js'; import { emoji, getAsset } from '../../../core/theme.js';
import * as matchLive from '../../../core/match-live.js'; import * as matchLive from '../../../core/match-live.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js';
let game, validMoves, ctx, canvas, boardSize, cellSize; let game, validMoves, ctx, canvas, boardSize, cellSize;
let diceAnimating = false; let diceAnimating = false;
...@@ -1142,9 +1143,16 @@ function renderMiniDice(miniDice, value) { ...@@ -1142,9 +1143,16 @@ function renderMiniDice(miniDice, value) {
).join(''); ).join('');
} }
function handleExit(el) { async function handleExit(el) {
if (game.gameOver) return; if (game.gameOver) return;
if (!confirm('هل تريد الخروج من المباراة؟')) return; const confirmed = await modal.confirm('هل تريد الخروج من المباراة؟', {
title: 'مغادرة',
icon: '🚪',
confirmText: 'نعم، اخرج',
cancelText: 'ابقَ',
danger: true
});
if (!confirmed) return;
game.gameOver = true; game.gameOver = true;
clearTurnTimer(); clearTurnTimer();
...@@ -1152,7 +1160,6 @@ function handleExit(el) { ...@@ -1152,7 +1160,6 @@ function handleExit(el) {
audio.play('click'); audio.play('click');
if (game.mode === 'live' && matchId) { 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(() => {}); net.post('ludo-match.php', { action: 'leave', match_id: matchId, player_index: myPlayerIndex }).catch(() => {});
mp.stopDisconnectWatch(); mp.stopDisconnectWatch();
if (livePoller) { clearInterval(livePoller); livePoller = null; } if (livePoller) { clearInterval(livePoller); livePoller = null; }
......
...@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js'; ...@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
...@@ -283,7 +284,14 @@ async function mountOtherProfile(el, playerId) { ...@@ -283,7 +284,14 @@ async function mountOtherProfile(el, playerId) {
const blockBtn = el.querySelector('#btn-block'); const blockBtn = el.querySelector('#btn-block');
if (blockBtn) { if (blockBtn) {
blockBtn.addEventListener('click', async () => { 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.disabled = true;
blockBtn.style.opacity = '0.5'; blockBtn.style.opacity = '0.5';
try { try {
......
...@@ -4,6 +4,7 @@ import * as audio from '../../../core/audio.js'; ...@@ -4,6 +4,7 @@ import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
...@@ -384,7 +385,14 @@ function bindFriendActions(content) { ...@@ -384,7 +385,14 @@ function bindFriendActions(content) {
audio.play('click'); audio.play('click');
const card = btn.closest('.friend-card'); const card = btn.closest('.friend-card');
const name = card?.querySelector('[style*="font-weight:600"]')?.textContent || 'هذا الصديق'; 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 { try {
await net.post('friends.php', { action: 'remove', target_id: btn.dataset.remove }); await net.post('friends.php', { action: 'remove', target_id: btn.dataset.remove });
card.style.transition = 'opacity 0.3s, transform 0.3s'; card.style.transition = 'opacity 0.3s, transform 0.3s';
......
...@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js'; ...@@ -3,6 +3,7 @@ import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { emoji } from '../../../core/theme.js';
...@@ -71,7 +72,14 @@ export async function mountGroupMembers(el, params = {}) { ...@@ -71,7 +72,14 @@ export async function mountGroupMembers(el, params = {}) {
// Remove buttons // Remove buttons
container.querySelectorAll('.remove-btn').forEach(btn => { container.querySelectorAll('.remove-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('إزالة هذا العضو من المجموعة؟')) return; const confirmed = await modal.confirm('إزالة هذا العضو من المجموعة؟', {
title: 'إزالة عضو',
icon: '👤',
confirmText: 'إزالة',
cancelText: 'إلغاء',
danger: true
});
if (!confirmed) return;
btn.disabled = true; btn.disabled = true;
try { try {
await net.post('groups.php', { action: 'remove-member', group_id: groupId, user_id: btn.dataset.id }); 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 = {}) { ...@@ -98,7 +106,14 @@ export async function mountGroupMembers(el, params = {}) {
// Leave group // Leave group
el.querySelector('#leave-btn').addEventListener('click', async () => { 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 { try {
await net.post('groups.php', { action: 'leave', group_id: groupId }); await net.post('groups.php', { action: 'leave', group_id: groupId });
audio.play('click'); 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