Commit 28883801 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(game): a full-screen level-up celebration

When the server raises your level, the table dims and the crown slams in on
spinning rays with the new level in a gold star, confetti, the level-up jingle
and a heavy haptic. shoot.mjs --eval runs a snippet before the shot.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 8cb1f74b
......@@ -4,7 +4,8 @@ import * as scene from './scene.js';
import { t } from './i18n.js';
import { assetImg } from './theme.js';
import { icon, gicon, crown } from './icons.js';
import { countUp } from './feel.js';
import { countUp, haptic } from './feel.js';
import * as juice from './juice.js';
import * as audio from './audio.js';
let hudEl, tabBar;
......@@ -82,6 +83,7 @@ function renderHud() {
purse['hud-coins'] = player?.coins || 0;
purse['hud-gems'] = player?.gems || 0;
lastLevel = player?.level || 1;
document.getElementById('hud-profile')?.addEventListener('click', () => {
bus.emit('navigate', { world: 'profile' });
......@@ -180,6 +182,10 @@ function updateHud() {
const levelEl = document.getElementById('hud-level');
if (levelEl) levelEl.textContent = player.level || 1;
// The server raised your level: that is a moment, not a number change.
const lvl = player.level || 1;
if (lastLevel && lvl > lastLevel) celebrateLevel(lvl);
lastLevel = lvl;
const xpEl = document.getElementById('hud-xp');
if (xpEl) xpEl.style.width = xpPct(player) + '%';
const nameEl = hudEl.querySelector('.hud-name');
......@@ -192,6 +198,48 @@ function updateHud() {
}
}
let lastLevel = 0;
/** Full-screen level-up: rays, the crown, the new level in a gold star. */
function celebrateLevel(level) {
if (document.querySelector('.lvl-up')) return;
const el = document.createElement('div');
el.className = 'lvl-up';
el.setAttribute('role', 'dialog');
el.setAttribute('aria-label', `${t('hud.level_up')} ${level}`);
el.innerHTML = `
<div class="lvl-stage">
<span class="lvl-rays"></span>
<span class="lvl-crown">${crown(120)}</span>
<span class="lvl-star"><span class="num">${level}</span></span>
</div>
<div class="lvl-title">${t('hud.level_up')}</div>
<div class="lvl-sub">${t('hud.level_up_sub', { n: `<span class="num">${level}</span>` })}</div>
<button class="btn btn-gold btn-lg lvl-ok">${t('hud.great')}</button>
<style>
.lvl-up { position: fixed; inset: 0; z-index: 400; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; background: rgba(12, 4, 0, 0.82); backdrop-filter: blur(3px); animation: fadeIn 240ms ease-out; text-align: center; padding: 24px; }
.lvl-stage { position: relative; width: 260px; height: 240px; display: flex; align-items: center; justify-content: center; }
.lvl-rays { position: absolute; inset: -40px; border-radius: 50%; background: repeating-conic-gradient(from 0deg, rgba(255, 214, 110, 0.35) 0deg 9deg, transparent 9deg 20deg); -webkit-mask: radial-gradient(circle, #000 15%, transparent 66%); mask: radial-gradient(circle, #000 15%, transparent 66%); animation: lvlSpin 12s linear infinite; }
@keyframes lvlSpin { to { transform: rotate(360deg); } }
.lvl-crown { position: relative; display: flex; filter: drop-shadow(0 10px 10px rgba(0, 0, 0, 0.5)); animation: slamIn 640ms var(--ease-out) both; }
.lvl-star { position: absolute; bottom: 4px; width: 92px; height: 92px; display: flex; align-items: center; justify-content: center; clip-path: polygon(50% 0, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); background: radial-gradient(circle at 50% 35%, #FFF2A8, #F2A01A 70%); animation: slamIn 640ms var(--ease-out) 260ms both; }
.lvl-star .num { font-family: var(--font-display); font-size: 36px; color: #fff; text-shadow: var(--text-outline); margin-top: 8px; }
.lvl-title { font-family: var(--font-display); font-size: 40px; color: var(--gold-soft); text-shadow: var(--text-outline), 0 6px 14px rgba(0, 0, 0, 0.5); animation: bounceIn 520ms var(--ease-out) 420ms both; }
.lvl-sub { font-family: var(--font-display); font-size: 18px; color: var(--text-primary); text-shadow: var(--text-outline); animation: bounceIn 520ms var(--ease-out) 560ms both; }
.lvl-ok { margin-top: 18px; min-width: 200px; animation: bounceIn 520ms var(--ease-out) 700ms both; }
</style>`;
document.body.appendChild(el);
audio.play('levelUp', 'reward');
haptic('heavy');
setTimeout(() => {
const r = el.querySelector('.lvl-crown')?.getBoundingClientRect();
if (r) juice.confetti(r.left + r.width / 2, r.top + r.height / 2, 60);
}, 300);
const close = () => { el.style.animation = 'fadeIn 200ms reverse forwards'; setTimeout(() => el.remove(), 200); };
el.querySelector('.lvl-ok').addEventListener('click', close);
setTimeout(() => el.isConnected && close(), 6000);
}
// The purse counts: a gain ticks up with a chime and the chip pops; a spend
// just settles on the new amount.
const purse = {};
......
......@@ -997,7 +997,10 @@ const strings = {
'splash.tip_2': 'نصيحة: اضغط على صندوق المكافأة كل يوم ليكبر رصيدك',
'splash.tip_3': 'نصيحة: في الشطرنج سيطر على المركز مبكراً',
'splash.tip_4': 'نصيحة: في الدومينو احتفظ بالدوبل الكبير للنهاية بحذر',
'splash.tip_5': 'نصيحة: في الطاولة لا تترك قطعة وحيدة أمام خصمك'
'splash.tip_5': 'نصيحة: في الطاولة لا تترك قطعة وحيدة أمام خصمك',
'hud.level_up': 'مستوى جديد!',
'hud.level_up_sub': 'وصلت إلى المستوى {n}',
'hud.great': 'رائع!'
},
en: {
'app.name': 'EL3AB',
......@@ -1995,7 +1998,10 @@ const strings = {
'splash.tip_2': 'Tip: open the reward chest every day to grow your coins',
'splash.tip_3': 'Tip: in chess, take the centre early',
'splash.tip_4': 'Tip: in domino, don\'t get stuck holding the big doubles',
'splash.tip_5': 'Tip: in backgammon, don\'t leave a single checker exposed'
'splash.tip_5': 'Tip: in backgammon, don\'t leave a single checker exposed',
'hud.level_up': 'Level up!',
'hud.level_up_sub': 'You reached level {n}',
'hud.great': 'Awesome!'
}
};
......
......@@ -32,6 +32,7 @@ const PUSH = argOf('--push', '');
const PARAMS = argOf('--params', '{}');
const NAME = argOf('--name', PUSH);
const SETTLE = +argOf('--settle', 1500);
const EVAL = argOf('--eval', ''); // JS run in the page after the scene opens, before the shot
const SB = 'https://safe-supabase-kong.caprover.al-arcade.com';
const SK = process.env.SUPABASE_SERVICE_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTg5MzQ1NjAwMH0.wNfmuJNkX-bZwD7RbjxOChlRf_3Xm4I7bswEYTcDCg4';
......@@ -181,6 +182,7 @@ try {
try {
current = s.id;
await goTo(page, s);
if (EVAL) { await page.evaluate(EVAL); await new Promise(r => setTimeout(r, 1200)); }
await page.screenshot({ path: path.join(OUT, `${s.id}.png`) });
console.log('shot', s.id);
} catch (e) { console.log('FAIL', s.id, e.message.slice(0, 100)); }
......
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