Commit ee14c2cd authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: THE JUICE UPDATE — full game-feel overhaul

New core module: juice.js
- Particle burst engine (coins, stars, confetti, squares)
- Coin fly-to-target animation (particles arc from source to HUD)
- Screen shake with intensity decay
- Number counter with eased tick-up
- Haptic feedback (light/medium/heavy/success/error patterns)
- Element glow/pulse effects
- Slam-in, bounce-in, slide-up-bounce animations
- Stagger utility for sequential element animations
- Float/breathe idle animations
- Full-screen flash effect

CSS juice system:
- 16 keyframe animations (slamIn, slideUpBounce, breatheGlow, ringExpand, wobble, numberPop, shimmerBorder, bgGradientMove...)
- Ambient moving gradient background on body
- Primary buttons breathe with golden glow when idle
- Scene transitions: spring overshoot (scale 0.88→1.02→1)
- will-change hints on interactive elements

Home screen juiced:
- Game tiles bounce-in staggered on load
- Tiles float with subtle Y-axis bob (different speeds)
- Tile press: haptic + pulse glow
- Game menu slams up with spring overshoot

Result screen juiced:
- Screen flash (green for win, red for loss)
- Trophy/skull slams in with 1.8x scale overshoot
- Rating counter ticks up/down per digit
- Win: confetti burst (40 particles) + haptic success
- Loss: screen shake + haptic error
- Coins fly from result area to HUD counter
- Buttons stagger slide-up on entry
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 00ea47bc
...@@ -144,11 +144,11 @@ html, body { ...@@ -144,11 +144,11 @@ html, body {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.scene-enter { animation: sceneIn 0.35s cubic-bezier(0.16, 1, 0.3, 1); } .scene-enter { animation: sceneIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); }
.scene-exit { animation: sceneOut 0.2s ease-in forwards; } .scene-exit { animation: sceneOut 0.15s ease-in forwards; }
@keyframes sceneIn { from { opacity: 0; transform: scale(0.95) translateY(20px); } } @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.02); } } @keyframes sceneOut { to { opacity: 0; transform: scale(1.05); } }
/* Cards */ /* Cards */
.card { .card {
...@@ -312,22 +312,54 @@ html, body { ...@@ -312,22 +312,54 @@ html, body {
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* Game-feel animations */ /* ========= JUICE SYSTEM ========= */
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } @keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
@keyframes glow { 0%, 100% { box-shadow: 0 0 5px var(--gold); } 50% { box-shadow: 0 0 20px var(--gold); } } @keyframes glow { 0%, 100% { box-shadow: 0 0 5px var(--gold); } 50% { box-shadow: 0 0 20px var(--gold); } }
@keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-3px); } 40% { transform: translateX(3px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } } @keyframes shake { 0%,100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } }
@keyframes popIn { 0% { transform: scale(0); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } } @keyframes popIn { 0% { transform: scale(0); opacity: 0; } 60% { transform: scale(1.15); } 100% { transform: scale(1); opacity: 1; } }
@keyframes coinFly { 0% { transform: translateY(0) scale(1); opacity: 1; } 100% { transform: translateY(-60px) scale(0.5); opacity: 0; } } @keyframes slamIn { 0% { transform: scale(1.5); opacity: 0; } 60% { transform: scale(0.92); opacity: 1; } 80% { transform: scale(1.04); } 100% { transform: scale(1); } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes bounceIn { 0% { transform: scale(0.3); opacity: 0; } 50% { transform: scale(1.1); } 70% { transform: scale(0.9); } 100% { transform: scale(1); opacity: 1; } } @keyframes slideUpBounce { 0% { transform: translateY(100%); } 70% { transform: translateY(-3%); } 100% { transform: translateY(0); } }
@keyframes bounceIn { 0% { transform: scale(0.3); opacity: 0; } 50% { transform: scale(1.08); } 70% { transform: scale(0.96); } 100% { transform: scale(1); opacity: 1; } }
@keyframes breatheGlow { 0%, 100% { box-shadow: 0 0 6px rgba(228,172,56,0.15); } 50% { box-shadow: 0 0 20px rgba(228,172,56,0.4); } }
@keyframes bgGradientMove { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
@keyframes numberPop { 0% { transform: scale(1); } 50% { transform: scale(1.3); } 100% { transform: scale(1); } }
@keyframes ringExpand { 0% { transform: scale(0.5); opacity: 1; } 100% { transform: scale(2.5); opacity: 0; } }
@keyframes wobble { 0%,100% { transform: rotate(0); } 25% { transform: rotate(-2deg); } 75% { transform: rotate(2deg); } }
@keyframes shimmerBorder { 0% { border-color: rgba(228,172,56,0.1); } 50% { border-color: rgba(228,172,56,0.3); } 100% { border-color: rgba(228,172,56,0.1); } }
.pulse { animation: pulse 2s ease-in-out infinite; } .pulse { animation: pulse 2s ease-in-out infinite; }
.float { animation: float 3s ease-in-out infinite; } .float { animation: float 3s ease-in-out infinite; }
.shake { animation: shake 0.4s ease; } .shake { animation: shake 0.3s ease; }
.pop-in { animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); } .pop-in { animation: popIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); }
.bounce-in { animation: bounceIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); } .slam-in { animation: slamIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); }
.bounce-in { animation: bounceIn 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); }
.slide-up { animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .slide-up { animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); }
.slide-up-bounce { animation: slideUpBounce 0.4s cubic-bezier(0.16, 1, 0.3, 1); }
.breathe-glow { animation: breatheGlow 2.5s ease-in-out infinite; }
.wobble { animation: wobble 0.5s ease; }
.number-pop { animation: numberPop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
/* Ambient background — subtle alive gradient */
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse at 30% 20%, rgba(228,172,56,0.03) 0%, transparent 50%),
radial-gradient(ellipse at 70% 80%, rgba(32,130,240,0.02) 0%, transparent 50%);
background-size: 200% 200%;
animation: bgGradientMove 25s ease infinite;
pointer-events: none;
z-index: 0;
}
/* Primary buttons breathe when idle */
.btn-primary { animation: breatheGlow 2.5s ease-in-out infinite; }
.btn-primary:active { animation: none; }
/* Performance hints */
.btn, .card, .game-tile { will-change: transform; }
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar { width: 4px; }
......
// EL3AB Juice Engine — particles, shake, counters, haptics, glow, bounce
// ============ PARTICLES ============
const particleContainer = document.createElement('div');
particleContainer.id = 'particles';
particleContainer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;overflow:hidden;';
document.body.appendChild(particleContainer);
export function burst(x, y, options = {}) {
const { count = 12, colors = ['#E4AC38', '#FFCC66', '#FFE082'], size = 8, spread = 120, duration = 800, gravity = true, type = 'circle' } = options;
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
const color = colors[Math.floor(Math.random() * colors.length)];
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5;
const velocity = spread * (0.5 + Math.random() * 0.5);
const dx = Math.cos(angle) * velocity;
const dy = Math.sin(angle) * velocity - (gravity ? 60 : 0);
const s = size * (0.5 + Math.random() * 0.8);
const rotation = Math.random() * 360;
if (type === 'star') {
el.textContent = '✦';
el.style.cssText = `position:absolute;left:${x}px;top:${y}px;font-size:${s * 2}px;color:${color};pointer-events:none;z-index:9999;`;
} else {
el.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:${s}px;height:${s}px;background:${color};border-radius:${type === 'square' ? '2px' : '50%'};pointer-events:none;z-index:9999;`;
}
particleContainer.appendChild(el);
el.animate([
{ transform: `translate(0,0) scale(1) rotate(0deg)`, opacity: 1 },
{ transform: `translate(${dx}px,${dy + (gravity ? 200 : 0)}px) scale(0) rotate(${rotation}deg)`, opacity: 0 }
], { duration: duration + Math.random() * 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', fill: 'forwards' })
.onfinish = () => el.remove();
}
}
export function coinBurst(x, y, count = 8) {
burst(x, y, { count, colors: ['#E4AC38', '#FFCC66', '#FFD700', '#FFC107'], size: 10, spread: 100, type: 'circle' });
}
export function starBurst(x, y, count = 6) {
burst(x, y, { count, colors: ['#E4AC38', '#FFCC66', '#FFF'], size: 8, spread: 80, type: 'star' });
}
export function confetti(x, y, count = 30) {
burst(x, y, { count, colors: ['#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'], size: 8, spread: 200, duration: 1200, type: 'square' });
}
// ============ COIN FLY TO TARGET ============
export function coinFlyTo(fromX, fromY, targetSelector, count = 5) {
const target = document.querySelector(targetSelector);
if (!target) return;
const rect = target.getBoundingClientRect();
const toX = rect.left + rect.width / 2;
const toY = rect.top + rect.height / 2;
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
const startX = fromX + (Math.random() - 0.5) * 40;
const startY = fromY + (Math.random() - 0.5) * 40;
el.style.cssText = `position:fixed;left:${startX}px;top:${startY}px;width:12px;height:12px;background:#E4AC38;border-radius:50%;box-shadow:0 0 6px #E4AC38;z-index:9999;pointer-events:none;`;
particleContainer.appendChild(el);
el.animate([
{ transform: 'scale(1)', left: startX + 'px', top: startY + 'px', opacity: 1 },
{ transform: 'scale(0.6)', left: toX + 'px', top: toY + 'px', opacity: 0.8 }
], { duration: 600 + i * 80, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'forwards', delay: i * 60 })
.onfinish = () => { el.remove(); if (i === count - 1) pulseElement(target); };
}
}
// ============ SCREEN SHAKE ============
export function shake(element, intensity = 4, duration = 300) {
const el = element || document.getElementById('game');
if (!el) return;
const keyframes = [];
const steps = Math.floor(duration / 30);
for (let i = 0; i < steps; i++) {
const progress = i / steps;
const decay = 1 - progress;
const x = (Math.random() - 0.5) * intensity * decay * 2;
const y = (Math.random() - 0.5) * intensity * decay * 2;
keyframes.push({ transform: `translate(${x}px, ${y}px)` });
}
keyframes.push({ transform: 'translate(0,0)' });
el.animate(keyframes, { duration, easing: 'linear' });
}
// ============ NUMBER COUNTER ============
export function countTo(element, target, options = {}) {
const { duration = 600, prefix = '', suffix = '', color } = options;
if (!element) return;
const start = parseInt(element.textContent.replace(/[^0-9-]/g, '')) || 0;
const diff = target - start;
if (diff === 0) { element.textContent = prefix + target + suffix; return; }
const startTime = performance.now();
if (color) element.style.color = color;
function tick(now) {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const current = Math.round(start + diff * eased);
element.textContent = prefix + current + suffix;
if (progress < 1) requestAnimationFrame(tick);
else { element.textContent = prefix + target + suffix; pulseElement(element); }
}
requestAnimationFrame(tick);
}
// ============ HAPTIC ============
export function haptic(pattern = 10) {
if (navigator.vibrate) {
navigator.vibrate(Array.isArray(pattern) ? pattern : [pattern]);
}
}
export function hapticLight() { haptic(8); }
export function hapticMedium() { haptic(15); }
export function hapticHeavy() { haptic([20, 30, 20]); }
export function hapticSuccess() { haptic([10, 50, 10, 50, 30]); }
export function hapticError() { haptic([50, 30, 50]); }
// ============ GLOW / PULSE ============
export function pulseElement(el, color = '#E4AC38', duration = 400) {
if (!el) return;
el.animate([
{ transform: 'scale(1)', boxShadow: `0 0 0px ${color}` },
{ transform: 'scale(1.1)', boxShadow: `0 0 20px ${color}` },
{ transform: 'scale(1)', boxShadow: `0 0 0px ${color}` }
], { duration, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' });
}
export function glowElement(el, color = '#E4AC38') {
if (!el) return;
el.style.boxShadow = `0 0 12px ${color}`;
el.style.transition = 'box-shadow 0.3s';
setTimeout(() => { el.style.boxShadow = ''; }, 1500);
}
// ============ BOUNCE / SLAM ============
export function slamIn(el, options = {}) {
if (!el) return;
const { scale = 1.3, duration = 400 } = options;
el.animate([
{ transform: `scale(${scale})`, opacity: 0 },
{ transform: 'scale(0.95)', opacity: 1, offset: 0.6 },
{ transform: 'scale(1.02)', offset: 0.8 },
{ transform: 'scale(1)', opacity: 1 }
], { duration, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', fill: 'forwards' });
}
export function bounceIn(el, delay = 0) {
if (!el) return;
el.style.opacity = '0';
setTimeout(() => {
el.style.opacity = '1';
el.animate([
{ transform: 'scale(0) translateY(20px)', opacity: 0 },
{ transform: 'scale(1.08)', opacity: 1, offset: 0.6 },
{ transform: 'scale(0.96)', offset: 0.8 },
{ transform: 'scale(1)', opacity: 1 }
], { duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', fill: 'forwards' });
}, delay);
}
export function slideUpBounce(el, delay = 0) {
if (!el) return;
el.style.opacity = '0';
setTimeout(() => {
el.style.opacity = '1';
el.animate([
{ transform: 'translateY(40px)', opacity: 0 },
{ transform: 'translateY(-4px)', opacity: 1, offset: 0.7 },
{ transform: 'translateY(0)', opacity: 1 }
], { duration: 400, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' });
}, delay);
}
export function stagger(elements, animFn, baseDelay = 0, interval = 60) {
elements.forEach((el, i) => animFn(el, baseDelay + i * interval));
}
// ============ FLASH ============
export function flash(color = '#fff', duration = 150) {
const el = document.createElement('div');
el.style.cssText = `position:fixed;inset:0;background:${color};z-index:9998;pointer-events:none;`;
document.body.appendChild(el);
el.animate([{ opacity: 0.6 }, { opacity: 0 }], { duration, fill: 'forwards' })
.onfinish = () => el.remove();
}
// ============ FLOATING / BREATHING ============
export function breathe(el, options = {}) {
if (!el) return;
const { scale = 1.02, duration = 2000, glow = false, glowColor = '#E4AC38' } = options;
const keyframes = glow
? [{ transform: 'scale(1)', boxShadow: `0 0 4px ${glowColor}` }, { transform: `scale(${scale})`, boxShadow: `0 0 16px ${glowColor}` }]
: [{ transform: 'scale(1)' }, { transform: `scale(${scale})` }];
return el.animate(keyframes, { duration, iterations: Infinity, direction: 'alternate', easing: 'ease-in-out' });
}
export function floatY(el, amount = 6, duration = 3000) {
if (!el) return;
return el.animate([
{ transform: 'translateY(0)' },
{ transform: `translateY(-${amount}px)` }
], { duration, iterations: Infinity, direction: 'alternate', easing: 'ease-in-out' });
}
...@@ -3,6 +3,7 @@ import * as audio from '../../../core/audio.js'; ...@@ -3,6 +3,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 { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import * as juice from '../../../core/juice.js';
export function mountResult(el, params) { export function mountResult(el, params) {
const { result, reason, coins = 0, xp = 0, moves = 0, mode, botId, pgn, moveHistory = [] } = params; const { result, reason, coins = 0, xp = 0, moves = 0, mode, botId, pgn, moveHistory = [] } = params;
...@@ -76,22 +77,61 @@ export function mountResult(el, params) { ...@@ -76,22 +77,61 @@ export function mountResult(el, params) {
</div> </div>
`; `;
// JUICE: Result screen effects
setTimeout(() => {
// Flash on entry
juice.flash(isWin ? '#34D39944' : '#EF444444', 200);
// Icon slams in
const icon = el.querySelector('[style*="font-size:72px"]');
if (icon) juice.slamIn(icon, { scale: 1.8, duration: 500 });
// Rating counter ticks
setTimeout(() => {
const ratingEl = el.querySelector('[style*="font-size:28px"]');
if (ratingEl && ratingChange) juice.countTo(ratingEl, ratingChange, { duration: 800, prefix: ratingChange >= 0 ? '+' : '', color: cfg.color });
}, 400);
// Confetti on win
if (isWin) {
setTimeout(() => juice.confetti(window.innerWidth / 2, window.innerHeight / 3, 40), 300);
juice.hapticSuccess();
} else {
juice.shake(el, 3, 200);
juice.hapticError();
}
// Coins fly to HUD
if (coins > 0) {
setTimeout(() => {
const rect = el.getBoundingClientRect();
juice.coinFlyTo(rect.width / 2, rect.height / 2, '#hud-coins', Math.min(coins / 10, 8));
}, 800);
}
// Stagger buttons
const buttons = el.querySelectorAll('.btn');
juice.stagger(Array.from(buttons), juice.slideUpBounce, 600, 80);
}, 100);
el.querySelector('#btn-rematch').addEventListener('click', () => { el.querySelector('#btn-rematch').addEventListener('click', () => {
audio.play('click'); audio.play('click');
juice.hapticLight();
scene.replace('chess-game', { mode, botId, timeControl: 'rapid_10_0' }); scene.replace('chess-game', { mode, botId, timeControl: 'rapid_10_0' });
}); });
el.querySelector('#btn-analyze').addEventListener('click', () => { el.querySelector('#btn-analyze').addEventListener('click', () => {
audio.play('click'); audio.play('click');
juice.hapticLight();
scene.push('chess-analysis', { pgn, moveHistory, finalFen: params.finalFen }); scene.push('chess-analysis', { pgn, moveHistory, finalFen: params.finalFen });
}); });
el.querySelector('#btn-back').addEventListener('click', () => { el.querySelector('#btn-back').addEventListener('click', () => {
audio.play('click'); audio.play('click');
juice.hapticLight();
bus.emit('navigate', { world: 'play', scene: 'play-table' }); bus.emit('navigate', { world: 'play', scene: 'play-table' });
}); });
// Update player coins in store
const player = store.get('player'); const player = store.get('player');
if (player && coins > 0) { if (player && coins > 0) {
store.set('player', { ...player, coins: (player.coins || 0) + coins, xp: (player.xp || 0) + xp }); store.set('player', { ...player, coins: (player.coins || 0) + coins, xp: (player.xp || 0) + xp });
......
...@@ -4,6 +4,7 @@ import * as store from '../../../core/store.js'; ...@@ -4,6 +4,7 @@ import * as store from '../../../core/store.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { assetImg } from '../../../core/theme.js'; import { assetImg } from '../../../core/theme.js';
import * as juice from '../../../core/juice.js';
const games = [ const games = [
{ key: 'chess', name: 'شطرنج', nameEn: 'Chess', color: '#2563EB', secondary: '#F5B731', icon: '♟', gradient: 'linear-gradient(135deg, #1e40af, #3b82f6)' }, { key: 'chess', name: 'شطرنج', nameEn: 'Chess', color: '#2563EB', secondary: '#F5B731', icon: '♟', gradient: 'linear-gradient(135deg, #1e40af, #3b82f6)' },
...@@ -193,9 +194,18 @@ export function mountTable(el) { ...@@ -193,9 +194,18 @@ export function mountTable(el) {
const menu = el.querySelector('#game-menu'); const menu = el.querySelector('#game-menu');
el.querySelectorAll('.game-tile').forEach(tile => { // Juice: staggered bounce-in for tiles + floating idle
const tiles = el.querySelectorAll('.game-tile');
juice.stagger(Array.from(tiles), juice.bounceIn, 100, 80);
setTimeout(() => {
tiles.forEach((tile, i) => juice.floatY(tile, 4 + i, 2500 + i * 500));
}, 600);
tiles.forEach(tile => {
tile.addEventListener('click', () => { tile.addEventListener('click', () => {
audio.play('click'); audio.play('click');
juice.hapticMedium();
juice.pulseElement(tile, tile.style.getPropertyValue('--game-color'));
const gameKey = tile.dataset.game; const gameKey = tile.dataset.game;
const game = games.find(g => g.key === gameKey); const game = games.find(g => g.key === gameKey);
showGameMenu(menu, game); showGameMenu(menu, game);
...@@ -205,6 +215,7 @@ export function mountTable(el) { ...@@ -205,6 +215,7 @@ export function mountTable(el) {
function showGameMenu(menu, game) { function showGameMenu(menu, game) {
menu.classList.remove('hidden'); menu.classList.remove('hidden');
menu.style.animation = 'slideUpBounce 0.4s cubic-bezier(0.16, 1, 0.3, 1)';
menu.innerHTML = ` menu.innerHTML = `
<div class="game-menu-header"> <div class="game-menu-header">
<div class="game-menu-title">${game.icon} ${game.name}</div> <div class="game-menu-title">${game.icon} ${game.name}</div>
......
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